-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Program marketplace #2985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: network-v2
Are you sure you want to change the base?
Program marketplace #2985
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the WalkthroughThe PR introduces a Partner Network feature enabling partners to discover and apply for network programs via a marketplace. It adds API endpoints for querying network programs with filtering and pagination, implements partner eligibility checks based on minimum payouts and enrollments, creates a marketplace UI with featured programs and dynamic filtering, and extends the Program schema with marketplace-related fields. Changes
Sequence DiagramsequenceDiagram
participant Partner as Partner
participant UI as Marketplace UI
participant API as API Routes
participant DB as Database
participant Auth as Auth Check
Partner->>UI: Navigate to Marketplace
UI->>Auth: Check eligibility via checkProgramNetworkRequirements
Auth->>DB: Fetch partner payouts & enrollments count
DB-->>Auth: Return counts
alt Eligible
Auth-->>UI: Pass
UI->>API: GET /api/network/programs?filters...
API->>DB: Query programs where marketplaceEnabledAt IS NOT NULL
DB-->>API: Return filtered programs with rewards/categories
API-->>UI: Return NetworkProgramSchema validated data
UI->>UI: Display featured programs & filtered list
else Not Eligible
Auth-->>UI: Redirect to /programs
end
Partner->>UI: Click on Program Card
UI->>API: GET /api/network/programs/{programSlug}
API->>DB: Fetch program details with groups & categories
DB-->>API: Return program data
API-->>UI: Return NetworkProgramExtendedSchema
UI->>UI: Display program detail with Apply/Accept buttons
Partner->>UI: Click Apply/Accept
UI->>API: POST application or accept action
API->>DB: Update enrollment status
DB-->>API: Confirm
API-->>UI: Success response
UI->>UI: Navigate to program dashboard
Estimated code review effort🎯 4 (Complex) | ⏱�� ~75 minutes Key areas requiring careful attention:
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Deployment failed with the following error: |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/ui/partners/program-application-sheet.tsx (1)
75-79: Fix success gating and submission flow; remove ts-ignore and avoid sending client-only fields
- Success UI is tied to isSubmitSuccessful, which can turn true on early returns or after setError; this may show a false “Application submitted.”
- Early return on missing partner/group data provides no feedback.
- The ts-ignore + placeholder formData is unnecessary; default it in form and let RHF provide real values.
- Don’t send termsAgreement to the action if the server schema doesn’t expect it.
Apply this consolidated patch:
@@ const form = useForm<FormData>({ defaultValues: { - termsAgreement: false, + termsAgreement: false, + // ensure formData exists even when no extra fields are rendered + formData: { fields: [] }, }, }); const { register, handleSubmit, setError, - formState: { errors, isSubmitting, isSubmitSuccessful }, + watch, + formState: { errors, isSubmitting }, } = form; + + const [isSuccess, setIsSuccess] = useState(false); @@ const { executeAsync } = useAction(createProgramApplicationAction, { onSuccess: () => { mutate(`/api/partner-profile/programs/${program!.slug}`); onSuccess?.(); + setIsSuccess(true); }, }); const onSubmit = async (data: FormData) => { - if (!group || !program || !partner?.email || !partner.country) return; + if (!group || !program) { + setError("root.serverError", { message: "Program data not loaded." }); + toast.error("Unable to load program details. Please refresh and try again."); + return; + } + if (!partner?.email || !partner?.country) { + setError("root.serverError", { message: "Partner profile incomplete." }); + toast.error("Complete your profile (email & country) to apply."); + return; + } - const result = await executeAsync({ - // @ts-ignore - formData: { fields: [] }, - ...data, + // strip client-only fields from payload + const { termsAgreement: _omit, ...payload } = data; + const result = await executeAsync({ + ...payload, email: partner.email, - name: partner.name, + name: partner.name ?? partner.email, country: partner.country, programId: program.id, groupId: group.id, }); if (result?.serverError || result?.validationErrors) { setError("root.serverError", { message: "Failed to submit application", }); toast.error(parseActionError(result, "Failed to submit application")); + return; } }; @@ - className={cn( + className={cn( "flex h-full flex-col transition-opacity duration-200", - isSubmitSuccessful && "pointer-events-none opacity-0", + isSuccess && "pointer-events-none opacity-0", )} {...{ - inert: isSubmitSuccessful, + inert: isSuccess, }} > @@ - isSubmitSuccessful + isSuccess ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-4 opacity-0", )} - inert={!isSubmitSuccessful} + inert={!isSuccess} >Also applies to: 81-87, 88-93, 95-116, 121-131, 233-240
apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/form.tsx (1)
106-107: Add category to button disabled check.The submit button's disabled state doesn't check for the
categoryfield, even though the field hasrequired: truevalidation (line 165). This creates an inconsistent UX where users can click submit without selecting a category, only to see a validation error.Apply this diff:
const buttonDisabled = - isSubmitting || isPending || !name || !url || !domain || !logo; + isSubmitting || isPending || !name || !url || !domain || !logo || !category;
🧹 Nitpick comments (33)
apps/web/ui/partners/partner-status-badges.ts (1)
62-72: LGTM! Implementation is correct.The spread syntax correctly creates a variant of
PartnerStatusBadgeswith contextually appropriate labels for program networks ("Enrolled" and "Applied"). The nested spreads preserve all other properties (variant, className, icon).Consider these optional improvements for maintainability:
- Add TypeScript type definitions to ensure consistency across both badge configurations:
type StatusBadgeConfig = { label: string; variant: string; className: string; icon: React.ComponentType; }; type StatusBadges = { pending: StatusBadgeConfig; approved: StatusBadgeConfig; rejected: StatusBadgeConfig; invited: StatusBadgeConfig; declined: StatusBadgeConfig; deactivated: StatusBadgeConfig; banned: StatusBadgeConfig; archived: StatusBadgeConfig; }; export const PartnerStatusBadges: StatusBadges = { // ... existing implementation }; export const ProgramNetworkStatusBadges: StatusBadges = { // ... existing implementation };
- Add JSDoc comments to clarify usage context:
/** * Status badges for partner applications in workspace context. */ export const PartnerStatusBadges = { ... }; /** * Status badges for program enrollment in network marketplace context. * Inherits all statuses from PartnerStatusBadges with adjusted labels: * - "Approved" → "Enrolled" * - "Pending" → "Applied" */ export const ProgramNetworkStatusBadges = { ... };apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx (1)
173-173: Good defensive programming.The fallback to 0 ensures
rowCountalways receives a defined numeric value, preventing potential issues whenpayoutsCountis undefined during loading or error states.Optionally, consider using the nullish coalescing operator for more precise handling:
- rowCount: payoutsCount || 0, + rowCount: payoutsCount ?? 0,This would only default to 0 for
nullorundefined, though in practice|| 0works equally well here since legitimate count values are positive numbers.apps/web/ui/partners/program-application-sheet.tsx (2)
223-229: Disable submit until terms are accepted (when termsUrl exists)Prevents avoidable round-trips and aligns UX with validation.
- <Button + <Button type="submit" variant="primary" text="Submit application" - disabled={isGroupLoading} + disabled={ + isGroupLoading || (program.termsUrl ? !watch("termsAgreement") : false) + } loading={isSubmitting} />
26-27: Avoid duplicate DOM ids for the terms checkboxMultiple open sheets will reuse id="termsAgreement". Use useId for uniqueness.
-import { Dispatch, SetStateAction, useState } from "react"; +import { Dispatch, SetStateAction, useId, useState } from "react"; @@ function ProgramApplicationSheetContent({ @@ }: ProgramApplicationSheetProps) { @@ const { partner } = usePartnerProfile(); + const termsId = useId(); @@ - <input + <input type="checkbox" - id="termsAgreement" + id={termsId} @@ - <label - htmlFor="termsAgreement" + <label + htmlFor={termsId} className="text-sm text-neutral-800" >Also applies to: 188-217
packages/prisma/schema/program.prisma (1)
67-70: Add marketplace indexes and consider Text for header image URL
- Likely queries: WHERE marketplaceEnabledAt IS NOT NULL and ORDER BY marketplaceFeaturedAt DESC with pagination. Add indexes to avoid seq scans. Example:
@@index([marketplaceEnabledAt]) @@index([marketplaceFeaturedAt])
- Header image URLs can exceed varchar defaults on some providers. For consistency with termsUrl/helpUrl, consider:
marketplaceHeaderImage String? @db.Textapps/web/lib/swr/use-program-enrollment.ts (1)
8-12: Good SWR gating with enabled flagKey is properly guarded; avoids unnecessary requests. Consider passing revalidateOnFocus: false if this endpoint is expensive.
Also applies to: 24-27
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx (1)
96-97: Chevron rotation may not trigger reliablygroup-data-[state=open] assumes data-state on the button. Use openPopover state instead.
- <ChevronDown className="size-4 flex-shrink-0 text-neutral-400 transition-transform duration-75 group-data-[state=open]:rotate-180" /> + <ChevronDown + className={cn( + "size-4 flex-shrink-0 text-neutral-400 transition-transform duration-75", + openPopover && "rotate-180", + )} + />apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx (2)
31-36: Align disabled state with program loadThe standalone “Application settings” button should match the popover trigger behavior when program is unavailable.
- <Button + <Button text="Application settings" onClick={() => setShowApplicationSettingsModal(true)} variant="secondary" className="hidden sm:flex" + disabled={!program} />
89-95: Add accessible name for icon-only triggerIcon-only buttons need an accessible label.
- <Button + <Button type="button" className="whitespace-nowrap px-2" variant="secondary" disabled={!program} icon={<ThreeDots className="size-4 shrink-0" />} + aria-label="Open applications menu" + title="Menu" />apps/web/lib/swr/use-partner-payouts-count.ts (2)
19-23: Scope SWR key by partnerId to prevent cross-user cache bleedIf the session’s defaultPartnerId changes, the URL key stays identical. Include partnerId in the key (and query) so data revalidates correctly.
- const { data: payoutsCount, error } = useSWR<T>( - partnerId && - `/api/partner-profile/payouts/count${getQueryString(query, { - include: includeParams, - })}`, + const { data: payoutsCount, error } = useSWR<T>( + partnerId && + `/api/partner-profile/payouts/count${getQueryString( + { ...(query || {}), partnerId }, + { include: [...includeParams, "partnerId"] }, + )}`, fetcher, { keepPreviousData: true, }, );If the API doesn’t accept partnerId, consider switching to a tuple key (e.g., [url, partnerId]) and updating fetcher accordingly.
30-34: Optional: expose mutate/isValidating for parity with other hooksHelpful for manual refresh and loading states.
- return { - payoutsCount, - error, - loading: !payoutsCount && !error, - }; + const { isValidating, mutate } = ({} as any); // from SWR's return if you destructure above + return { payoutsCount, error, loading: !payoutsCount && !error, isValidating, mutate };apps/web/lib/zod/schemas/program-onboarding.ts (1)
20-25: Single category vs. array elsewhere — confirm mappingOn onboarding you accept a single category (Category | null). Programs schema elsewhere uses categories: Category[]. Ensure create/update actions correctly map this to the join table and UI that expects arrays.
If you plan multi-select soon, consider aligning to z.array(z.nativeEnum(Category)).min(1) here to avoid future migrations.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx (1)
1-13: LGTM: page wrapper is cleanOptional: add route-level metadata for title/OG if desired.
apps/web/app/(ee)/api/network/programs/check-program-network-requirements.ts (1)
8-12: Add explicit return type for clarity and safer contracts.Annotate the function to return Promise.
-export async function checkProgramNetworkRequirements({ +export async function checkProgramNetworkRequirements({ partner, }: { partner: Pick<PartnerProps, "id">; -}) { +}): Promise<boolean> {apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/marketplace-empty-state.tsx (1)
15-23: Use semantic and ensure Esc actually clears filters.Replace the styled span with a . Also verify a keydown Escape handler is wired where this component is used; otherwise the hint misleads.
- <span className="text-content-default bg-bg-emphasis rounded-md px-1 py-0.5 text-xs font-semibold"> - Esc - </span>{" "} + <kbd className="text-content-default bg-bg-emphasis rounded-md px-1 py-0.5 text-xs font-semibold"> + Esc + </kbd>{" "} to clear all filters.Also applies to: 41-47
apps/web/lib/zod/schemas/program-network.ts (1)
41-51: Deduplicate and normalize rewardType parsing.Avoid duplicates and normalize to a string[] of allowed values.
export const getNetworkProgramsQuerySchema = z .object({ category: z.nativeEnum(Category).optional(), - rewardType: z - .union([z.string(), z.array(rewardTypeSchema)]) - .transform((v) => - Array.isArray(v) - ? v - : v.split(",").filter((v) => rewardTypes.includes(v as any)), - ) - .optional(), + rewardType: z + .union([z.string(), z.array(rewardTypeSchema)]) + .transform((v) => + Array.isArray(v) ? v : v.split(",").filter(Boolean), + ) + .transform((arr) => + Array.from(new Set(arr.filter((v) => rewardTypes.includes(v as any)))), + ) + .optional(),apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx (4)
81-83: Format labels with all underscores replaced.- getOptionLabel: (value) => - categoriesMap[value]?.label || value.replace("_", " "), + getOptionLabel: (value) => + categoriesMap[value]?.label || value.replaceAll("_", " "),
138-144: Normalize multi-select from URL and deduplicate values.- const multiFilters = useMemo( - () => ({ - rewardType: searchParamsObj.rewardType?.split(",").filter(Boolean) ?? [], - }), - [searchParamsObj], - ) as Record<string, string[]>; + const multiFilters = useMemo( + () => ({ + rewardType: Array.from( + new Set(searchParamsObj.rewardType?.split(",").filter(Boolean) ?? []), + ), + }), + [searchParamsObj], + ) as Record<string, string[]>;
158-171: Prevent duplicate values when selecting multi-filters.const onSelect = useCallback( (key: string, value: any) => queryParams({ set: Object.keys(multiFilters).includes(key) ? { - [key]: multiFilters[key].concat(value).join(","), + [key]: Array.from(new Set(multiFilters[key].concat(value))).join( + ",", + ), } : { [key]: value, }, del: "page", }),
111-133: Return empty array for status options instead of null.Keeps consumer components simpler and avoids null checks.
- options: - statusCount?.map(({ status, _count }) => { + options: + statusCount?.map(({ status, _count }) => { // ... - }) ?? null, + }) ?? [],apps/web/ui/partners/program-network/program-category.tsx (1)
16-19: Polish fallback label + minor a11y UX
- Replace only-first-underscore with replaceAll for multi-word categories.
- Add a title so truncated labels are discoverable.
- const { icon: Icon, label } = categoryData ?? { - icon: CircleInfo, - label: category.replace("_", " "), - }; + const { icon: Icon, label } = categoryData ?? { + icon: CircleInfo, + label: category.replaceAll("_", " "), + };- <span className="min-w-0 truncate text-sm font-medium">{label}</span> + <span title={label} className="min-w-0 truncate text-sm font-medium"> + {label} + </span>Also applies to: 21-21, 33-34
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx (1)
28-29: Consider honoring reduced‑motion for autoplayOptionally disable
autoplaywhen the user prefers reduced motion. If@dub/ui’sCarouseldoesn’t handle this internally, passautoplay={false}whenmatchMedia('(prefers-reduced-motion: reduce)')is true.apps/web/ui/partners/program-category-select.tsx (1)
2-3: Tighten Combobox types and avoidanycasts; memoize optionsUse a typed
OptionanduseMemofor options to prevent reallocation and dropsas Category | null.-import { Combobox, ComboboxProps } from "@dub/ui"; +import { Combobox, ComboboxProps } from "@dub/ui"; +import { useMemo } from "react"; @@ -} & Omit<ComboboxProps<false, any>, "selected" | "options" | "onSelect">) { +} & Omit<ComboboxProps<false, { value: Category; label: string; icon?: any }>, "selected" | "options" | "onSelect">) { - const selectedCategory = selected ? categoriesMap?.[selected] : null; + const selectedCategory = selected ? categoriesMap?.[selected] : null; + const options = useMemo( + () => categories.map(({ id, label, icon }) => ({ value: id, label, icon })), + [], + ); @@ - <Combobox - options={categories.map(({ id, label, icon }) => ({ - value: id, - label, - icon, - }))} + <Combobox + options={options} @@ - onSelect={(option) => onChange(option?.value as Category | null)} + onSelect={(option) => onChange(option ? option.value : null)}Also applies to: 12-13, 16-21, 31-35
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx (2)
87-91: Error state exists; consider making it screen‑reader friendlyAdd
role="status"andaria-live="polite"so the error is announced.- <div className="text-content-subtle py-12 text-sm"> + <div className="text-content-subtle py-12 text-sm" role="status" aria-live="polite"> Failed to load programs </div>
100-105: Match skeleton count to page sizeUse
PROGRAM_NETWORK_MAX_PAGE_SIZEfor placeholder count to reduce layout shift when page size changes.- : [...Array(5)].map((_, idx) => <ProgramCard key={idx} />)} + : [...Array(PROGRAM_NETWORK_MAX_PAGE_SIZE)].map((_, idx) => ( + <ProgramCard key={idx} /> + ))}apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx (2)
54-58: Lazy‑load images to improve LCP and bandwidthAdd
loading="lazy"anddecoding="async"to non-critical images.- <img + <img src={program.logo || `${OG_AVATAR_URL}${program.name}`} alt={program.name} - className="size-12 rounded-full" + className="size-12 rounded-full" + loading="lazy" + decoding="async" />Apply similarly to the featured card avatar. The header image is background-like; leaving it eager is fine due to above-the-fold placement.
Also applies to: 238-242, 226-233
97-139: Deduplicate Rewards/Industry blocks across both cardsThe two sections are nearly identical. Extract a small
<ProgramMeta>subcomponent to reduce duplication and keep interactions consistent.I can draft a
ProgramMetacomponent that takes{ program, inverted?: boolean }and consolidates the reward/category UI.Also applies to: 140-189, 283-327, 328-377
apps/web/app/(ee)/api/network/programs/route.ts (2)
44-49: Make search case-insensitive.Current
containsis case-sensitive in Prisma. Addmode: "insensitive"to improve UX.- ...(search && { - OR: [ - { name: { contains: search } }, - { slug: { contains: search } }, - { domain: { contains: search } }, - ], - }), + ...(search && { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { slug: { contains: search, mode: "insensitive" } }, + { domain: { contains: search, mode: "insensitive" } }, + ], + }),
107-116:sortOrderis ignored for popularity.If clients pass
sortOrder=ascwithsortBy=popularity, it’s dropped. Either document “popularity is always desc” or honorsortOrder.- orderBy: - sortBy === "popularity" - ? { - partners: { - _count: "desc", - }, - } - : { [sortBy]: sortOrder }, + orderBy: + sortBy === "popularity" + ? { partners: { _count: sortOrder } } + : { [sortBy]: sortOrder },apps/web/lib/actions/partners/update-auto-approve-partners.ts (1)
50-67: Consider logging marketplace/category changes too.Add audit logs when
marketplaceEnabledtoggles and when category changes for traceability.waitUntil( Promise.allSettled([ ...(autoApprovePartners !== undefined ? [ recordAuditLog({ /* as above */ }), ] : []), + ...(marketplaceEnabled !== undefined + ? [ + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: marketplaceEnabled + ? "marketplace.enabled" + : "marketplace.disabled", + description: marketplaceEnabled + ? "Marketplace enabled" + : "Marketplace disabled", + actor: user, + }), + ] + : []), + ...(category !== undefined + ? [ + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "program.category.set", + description: `Program category ${category ?? "cleared"}`, + actor: user, + }), + ] + : []), ]), );apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (1)
112-123: Avoid truthy gating for counts; use explicit numeric checks.Truthiness hides Marketplace when thresholds are 0 or counts are 0 but valid. Compare as numbers.
- ...(enrolledProgramsCount && - enrolledProgramsCount >= PROGRAM_NETWORK_PARTNER_MIN_PROGRAMS && - payoutsCount && - payoutsCount >= PROGRAM_NETWORK_PARTNER_MIN_PAYOUTS + ...(typeof enrolledProgramsCount === "number" && + enrolledProgramsCount >= PROGRAM_NETWORK_PARTNER_MIN_PROGRAMS && + typeof payoutsCount === "number" && + payoutsCount >= PROGRAM_NETWORK_PARTNER_MIN_PAYOUTS ? [ { name: "Marketplace", icon: Shop, href: "/programs/marketplace" as `/${string}`, }, ] : []),apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page-client.tsx (2)
276-281: Also mutate network programs cache after accepting an invite.Keeps marketplace views in sync.
- onSuccess: async () => { - await mutatePrefix("/api/partner-profile/programs"); + onSuccess: async () => { + await Promise.all([ + mutatePrefix("/api/partner-profile/programs"), + mutatePrefix("/api/network/programs"), + ]); toast.success("Program invite accepted!"); router.push(`/programs/${program.slug}`); },
39-45: Handle load errors to avoid a blank state.Render an error UI when
erroris set and!program.- const { data: program, error } = useSWR<NetworkProgramExtendedProps>( + const { data: program, error } = useSWR<NetworkProgramExtendedProps>( programSlug ? `/api/network/programs/${programSlug}` : null, fetcher, { keepPreviousData: true }, ); + if (error && !program) { + return ( + <PageContent title="Program details"> + <PageWidthWrapper> + <div className="mx-auto mt-10 max-w-screen-sm text-sm text-red-600"> + Failed to load program. Please retry. + </div> + </PageWidthWrapper> + </PageContent> + ); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (47)
apps/web/app/(ee)/api/network/programs/[programSlug]/route.ts(1 hunks)apps/web/app/(ee)/api/network/programs/check-program-network-requirements.ts(1 hunks)apps/web/app/(ee)/api/network/programs/count/route.ts(1 hunks)apps/web/app/(ee)/api/network/programs/route.ts(1 hunks)apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts(1 hunks)apps/web/app/(ee)/api/programs/[programId]/route.ts(1 hunks)apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/form.tsx(4 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/payouts/payout-table.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page-client.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/layout.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/marketplace-empty-state.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx(2 hunks)apps/web/lib/actions/partners/create-program.ts(2 hunks)apps/web/lib/actions/partners/update-auto-approve-partners.ts(2 hunks)apps/web/lib/api/programs/get-program-or-throw.ts(2 hunks)apps/web/lib/partners/categories.ts(1 hunks)apps/web/lib/swr/use-network-programs-count.ts(1 hunks)apps/web/lib/swr/use-partner-payouts-count.ts(2 hunks)apps/web/lib/swr/use-program-enrollment.ts(2 hunks)apps/web/lib/types.ts(2 hunks)apps/web/lib/zod/schemas/groups.ts(1 hunks)apps/web/lib/zod/schemas/program-network.ts(1 hunks)apps/web/lib/zod/schemas/program-onboarding.ts(2 hunks)apps/web/lib/zod/schemas/programs.ts(2 hunks)apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx(7 hunks)apps/web/ui/modals/application-settings-modal.tsx(1 hunks)apps/web/ui/partners/lander/lander-hero.tsx(1 hunks)apps/web/ui/partners/partner-status-badges.ts(1 hunks)apps/web/ui/partners/program-application-sheet.tsx(5 hunks)apps/web/ui/partners/program-card.tsx(0 hunks)apps/web/ui/partners/program-category-select.tsx(1 hunks)apps/web/ui/partners/program-network/program-category.tsx(1 hunks)apps/web/ui/partners/program-network/program-reward-icon.tsx(1 hunks)packages/prisma/schema/program.prisma(1 hunks)packages/ui/src/combobox/index.tsx(2 hunks)packages/ui/src/icons/nucleo/index.ts(2 hunks)packages/ui/src/icons/nucleo/shop.tsx(1 hunks)packages/ui/src/icons/nucleo/sort-alpha-ascending.tsx(1 hunks)packages/ui/src/icons/nucleo/sort-alpha-descending.tsx(1 hunks)packages/ui/src/icons/nucleo/suitcase.tsx(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/ui/partners/program-card.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-15T01:05:43.266Z
Learnt from: steven-tey
PR: dubinc/dub#2958
File: apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx:432-457
Timestamp: 2025-10-15T01:05:43.266Z
Learning: In apps/web/app/app.dub.co/(dashboard)/[slug]/settings/members/page-client.tsx, defer refactoring the custom MenuItem component (lines 432-457) to use the shared dub/ui MenuItem component to a future PR, as requested by steven-tey.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/applications-menu.tsx
🔇 Additional comments (30)
packages/ui/src/combobox/index.tsx (2)
143-144: LGTM! Proper null safety for optional setter.The early return correctly guards against calling
setSelectedwhen it's undefined, which is valid given the optional type definition. This prevents potential runtime crashes in multi-select mode.
154-157: LGTM! Appropriate use of optional chaining.The optional chaining correctly handles the case where
setSelectedis undefined while ensuringsetIsOpen(false)on line 157 still executes. This approach is more suitable than an early return here since the popover should close regardless of whether a setter is provided.packages/ui/src/icons/nucleo/shop.tsx (1)
1-65: LGTM! Clean icon implementation.The Shop icon component follows the established Nucleo icon pattern correctly. It's a pure presentational component with proper TypeScript types, spread props for customization, and consistent use of
currentColorfor theming.packages/ui/src/icons/nucleo/suitcase.tsx (1)
1-37: LGTM! Consistent implementation.The Suitcase icon component is correctly implemented following the same pattern as other Nucleo icons. Clean, simple, and ready to use.
packages/ui/src/icons/nucleo/sort-alpha-descending.tsx (1)
1-62: LGTM! Well-structured sorting icon.The SortAlphaDescending icon component is correctly implemented with consistent structure and styling. Pairs appropriately with the ascending variant.
packages/ui/src/icons/nucleo/sort-alpha-ascending.tsx (1)
1-62: LGTM! Matching ascending variant.The SortAlphaAscending icon component is correctly implemented and appropriately mirrors the descending variant. Clean and consistent.
packages/ui/src/icons/nucleo/index.ts (1)
206-206: LGTM! Exports properly organized.The new icon exports are correctly added in alphabetical order and follow the established barrel export pattern. All referenced icon modules are present and properly implemented.
Also applies to: 210-211, 221-221
apps/web/ui/partners/program-application-sheet.tsx (1)
35-41: Nice: narrowed program prop and flexible back destinationThe Pick<> typing reduces prop surface, and the backDestination-driven href/text simplifies re-use between programs and marketplace. LGTM.
Also applies to: 269-280
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx (1)
15-22: Backend already supports sortBy=popularity—no action neededThe API schema (
apps/web/lib/zod/schemas/program-network.ts:58) validates"popularity"as a validsortByvalue, and the route handler (apps/web/app/(ee)/api/network/programs/route.ts:108) explicitly implements the corresponding orderBy logic. The UI change aligns correctly with existing backend support.apps/web/app/(ee)/api/programs/[programId]/route.ts (1)
7-13: LGTM; no breaking changes confirmedThe
categoriesfield is already optional inProgramSchema(defined as.nullish()atapps/web/lib/zod/schemas/programs.ts:42), so enriching this endpoint's payload is non-breaking. All other callers ofgetProgramOrThrowomit theincludeCategoriesparameter, preserving their current behavior. Downstream consumers will safely ignore the additional field or utilize it as needed.apps/web/lib/api/programs/get-program-or-throw.ts (1)
33-41: Refactor suggestion is correct—remove ts-ignore with explicit type branchingThe verification confirms the refactor is sound:
ProgramCategorymodel contains{ programId: String, category: Category }fields- When
includeCategories=true, Prisma returnsProgramCategory[]objects- The mapping
{ category }correctly extractsCategory[]values matchingProgramSchema- The suggested refactor properly types this conditional transformation and eliminates the
@ts-ignoreThe branching approach with type assertion safely handles both code paths and maintains correctness with
ProgramSchemavalidation.apps/web/lib/types.ts (1)
100-104: LGTM: network program types wired to zod schemasType exports look correct; no issues.
Also applies to: 464-468
apps/web/lib/swr/use-partner-payouts-count.ts (1)
7-14: All call sites verified as compatible—no issues found.All 7 usages across the codebase (partners-sidebar-nav.tsx, payout-stats.tsx, marketplace/layout.tsx, payouts-card.tsx, payouts/payout-stats.tsx, use-payout-filters.tsx, and payout-table.tsx) are already correctly aligned with the current signature
(query?: Record<string, string>, { includeParams? }? = {}). Whether passing two explicit parameters, a single query object, or no arguments, all call sites work correctly with the existing signature.apps/web/lib/zod/schemas/programs.ts (1)
7-7: LGTM! Clean schema extension.The addition of
marketplaceEnabledAtandcategoriesfields to ProgramSchema follows the established pattern of usingnullish()for optional fields, consistent with other similar fields likelanderPublishedAtandautoApprovePartnersEnabledAt.Also applies to: 37-37, 42-42
apps/web/lib/zod/schemas/groups.ts (1)
84-88: LGTM! Appropriate schema subset.The
PartnerProgramGroupSchemacorrectly exposes a minimal public subset of group data (id, slug, applicationFormData) for partner-facing endpoints, which follows the principle of least privilege for data exposure.apps/web/lib/actions/partners/create-program.ts (2)
42-42: LGTM! Category field properly destructured.The category field is correctly extracted from the onboarding data and used in the conditional upsert logic below.
188-202: Consider future category update logic.The conditional upsert creates a category association when present, but there's no corresponding deletion logic if a category is later removed or changed. While this is acceptable for program creation, ensure that any future program update flows handle category modifications appropriately to avoid orphaned associations.
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx (1)
1-5: LGTM! Standard Next.js pattern.This server component wrapper follows the conventional Next.js pattern for delegating rendering to a client component.
apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts (1)
1-16: LGTM! Clean API endpoint implementation.The route handler follows best practices: uses authentication wrapper (
withPartnerProfile), delegates validation to utility function (getGroupOrThrow), and ensures type safety with schema validation (PartnerProgramGroupSchema).apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/form.tsx (2)
33-39: LGTM! Category field properly tracked.The category field is correctly added to the watch array for reactive form state management.
157-174: LGTM! Category field properly implemented.The Product industry field is correctly implemented using Controller with required validation, ensuring the category is captured during program creation.
apps/web/lib/partners/categories.ts (1)
17-81: All Category enum values are properly mapped—verification complete.Confirmed that all 11 values from the Category enum in
packages/prisma/schema/program.prisma(Artificial_Intelligence, Development, Design, Productivity, Finance, Marketing, Ecommerce, Security, Education, Health, Consumer) have corresponding entries in thecategoriesarray. The implementation is complete and type-safe.apps/web/ui/partners/lander/lander-hero.tsx (1)
21-25: LGTM — flexible heading + label gating look solidNice polymorphic Heading selection and optional label block; className merge is clean.
Also applies to: 25-34
apps/web/ui/partners/program-network/program-reward-icon.tsx (1)
1-1: Fix HoverCard trigger semantics and a11y; avoidIconshadowing
- Use
asChildon Trigger to prevent nested interactive elements.- Rename
iconvar to avoid shadowing the imported type.- Add
aria-label(andtabIndexwhen not clickable) for keyboard users.-import { Icon } from "@dub/ui"; +import type { Icon as IconType } from "@dub/ui"; @@ -export const ProgramRewardIcon = ({ - icon: Icon, +export const ProgramRewardIcon = ({ + icon: IconComponent, description, onClick, className, }: { - icon: Icon; + icon: IconType; description: string; onClick?: () => void; className?: string; }) => { const As = onClick ? "button" : "div"; @@ - > - <Icon className="text-content-default size-4" /> + > + <IconComponent className="text-content-default size-4" /> <span>{description}</span> </HoverCard.Content> </HoverCard.Portal> - <HoverCard.Trigger> + <HoverCard.Trigger asChild> <As - {...(onClick && { type: "button", onClick })} + {...(onClick && { type: "button", onClick })} + {...(!onClick && { tabIndex: 0 })} + aria-label={description} className={cn( "text-content-default flex size-6 items-center justify-center rounded-md", onClick && "hover:bg-bg-subtle active:bg-bg-emphasis", className, )} > - <Icon className="size-4" /> + <IconComponent className="size-4" /> </As> </HoverCard.Trigger>Also applies to: 5-15, 26-26, 30-41, 39-39
⛔ Skipped due to learnings
Learnt from: TWilson023 PR: dubinc/dub#2872 File: apps/web/ui/partners/partner-about.tsx:11-11 Timestamp: 2025-09-24T16:10:37.349Z Learning: In the Dub codebase, the team prefers to import Icon as a runtime value from "dub/ui" and uses Icon as both a type and variable name in component props, even when this creates shadowing. This is their established pattern and should not be suggested for refactoring.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx (1)
55-61: Typing of CarouselCard is fine; keep it localThe wrapper using
ComponentProps<typeof FeaturedProgramCard>is correct and keeps props in sync. No changes needed.apps/web/ui/partners/program-category-select.tsx (1)
25-28: Graceful label fallback is good
selected.replaceAll("_", " ")is a sensible fallback when no mapping exists.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx (1)
33-37: SWR usage looks goodStable key,
keepPreviousData, andrevalidateOnFocus: falseare appropriate here.apps/web/lib/swr/use-network-programs-count.ts (1)
21-23: Workspace dependency is fineAssuming
useWorkspace()always providesidin Partner Dashboard, this is OK. With the above filter, transientundefinedwon’t leak.Do you ever call this hook outside workspace context? If so, we should guard for missing
workspaceId.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx (1)
17-33: Aux/middle‑click handler is fineCustom
onAuxClickand interactive-child guard are good for preserving expected behaviors.apps/web/app/(ee)/api/network/programs/route.ts (1)
115-116: Pagination constraints are properly enforced by the schema.The schema uses
getPaginationQuerySchemawith validators that ensurepageis positive (defaults to 1 if omitted) andpageSizedoes not exceed 100. These constraints prevent negativeskipvalues and invalidtakeinputs at the point of parsing, before the code at lines 115–116 executes.
Summary by CodeRabbit
Release Notes
New Features
UI Improvements