From cf55a8e9692da6a5d2acf4173fe70d4f5a7fb877 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 04:56:16 +0000 Subject: [PATCH 1/7] feat(compete): registration add-ons (merch) gated by entitlement Implements Option A from the merch add-ons research memo: in-flow registration add-ons with a platform-owned catalog, sold inside the existing Stripe Checkout Session. - New registration_addons feature entitlement; platform admins enable merch per organizing team via /admin/entitlements. Server fns enforce the gate (CRUD throws, public catalog returns empty, checkout rejects addOns input for unentitled teams). - Catalog schema: competition_products + competition_product_variants (sizes, optional per-variant stock); commerce_purchases gains variantId + quantity. - initiateRegistrationPaymentFn accepts addOns[]: validates entitlement, availability (order-by deadline, end-of-day in comp timezone), variants, per-athlete caps, soft stock; appends per-unit all-in line items (percentage platform fee only, no $2 fixed fee on merch). Free division + paid shirt routes through Stripe; coupons never discount merch. - Checkout workflow ADDON branch: atomic variant stock claim with auto-refund on oversell (reverse_transfer), group refund when every registration in the session failed, PAYMENT_COMPLETED ledger events. - Athlete UI: Event merch order-bump section in both registration form variants with fee summary lines. - Organizer UI: Merch page (catalog CRUD, locked state when not entitled, counts-by-variant print-shop report, pickup list) + sidebar entry. - Tests: availability/fee-math units, entitlement-gate server fn tests, workflow ADDON branch coverage. lat.md docs updated. Schema changes apply via pnpm db:push (no MySQL migration journal exists yet); production needs the feat_registration_addons feature row inserted once, mirroring the seed. https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi --- .../scripts/seed/seeders/02-billing.ts | 13 + .../src/components/competition-sidebar.tsx | 2 + .../registration/addons-section.tsx | 223 +++++ .../registration/registration-form.tsx | 18 + .../registration/registration-sections.tsx | 72 +- .../registration/use-registration-form.ts | 100 +++ apps/wodsmith-start/src/config/features.ts | 1 + apps/wodsmith-start/src/db/schema.ts | 1 + .../src/db/schemas/competition-products.ts | 124 +++ apps/wodsmith-start/src/routeTree.gen.ts | 23 + .../src/routes/compete/$slug/register.tsx | 10 + .../compete/organizer/$competitionId.tsx | 1 + .../organizer/$competitionId/merch.tsx | 774 ++++++++++++++++++ .../src/server-fns/competition-addon-fns.ts | 750 +++++++++++++++++ .../src/server-fns/registration-fns.ts | 237 +++++- .../src/server/commerce/addons.ts | 46 ++ .../src/utils/addon-availability.ts | 88 ++ .../src/workflows/stripe-checkout-workflow.ts | 276 ++++++- .../server-fns/competition-addon-fns.test.ts | 285 +++++++ .../test/server/commerce/addons.test.ts | 71 ++ .../test/utils/addon-availability.test.ts | 108 +++ .../stripe-checkout-workflow.test.ts | 154 ++++ docs/plans/registration-addons-plan.md | 135 +++ lat.md/commerce.md | 30 + lat.md/organizer-dashboard.md | 6 + lat.md/registration.md | 11 +- 26 files changed, 3491 insertions(+), 68 deletions(-) create mode 100644 apps/wodsmith-start/src/components/registration/addons-section.tsx create mode 100644 apps/wodsmith-start/src/db/schemas/competition-products.ts create mode 100644 apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx create mode 100644 apps/wodsmith-start/src/server-fns/competition-addon-fns.ts create mode 100644 apps/wodsmith-start/src/server/commerce/addons.ts create mode 100644 apps/wodsmith-start/src/utils/addon-availability.ts create mode 100644 apps/wodsmith-start/test/server-fns/competition-addon-fns.test.ts create mode 100644 apps/wodsmith-start/test/server/commerce/addons.test.ts create mode 100644 apps/wodsmith-start/test/utils/addon-availability.test.ts create mode 100644 docs/plans/registration-addons-plan.md diff --git a/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts b/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts index 7d281183e..7d74989cf 100644 --- a/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts +++ b/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts @@ -264,6 +264,19 @@ export async function seed(client: Connection): Promise { updated_at: ts, update_counter: 0, }, + // @lat: [[commerce#Registration Add-ons#Entitlement]] + { + id: "feat_registration_addons", + key: "registration_addons", + name: "Registration Add-ons", + description: + "Sell merch and add-ons (e.g., event tees) during competition registration", + category: "team", + is_active: 1, + created_at: ts, + updated_at: ts, + update_counter: 0, + }, ]) // Limits diff --git a/apps/wodsmith-start/src/components/competition-sidebar.tsx b/apps/wodsmith-start/src/components/competition-sidebar.tsx index ad68c287a..56e59d250 100644 --- a/apps/wodsmith-start/src/components/competition-sidebar.tsx +++ b/apps/wodsmith-start/src/components/competition-sidebar.tsx @@ -28,6 +28,7 @@ import { Menu, ReceiptText, Settings, + ShoppingBag, Sparkles, Tag, Trophy, @@ -222,6 +223,7 @@ const getNavigation = ( { label: "Pricing", href: `${basePath}/pricing`, icon: ReceiptText }, { label: "Revenue", href: `${basePath}/revenue`, icon: DollarSign }, { label: "Coupons", href: `${basePath}/coupons`, icon: Tag }, + { label: "Merch", href: `${basePath}/merch`, icon: ShoppingBag }, { label: "Sponsors", href: `${basePath}/sponsors`, icon: Sparkles }, { label: "Co-Hosts", diff --git a/apps/wodsmith-start/src/components/registration/addons-section.tsx b/apps/wodsmith-start/src/components/registration/addons-section.tsx new file mode 100644 index 000000000..5c4c8d854 --- /dev/null +++ b/apps/wodsmith-start/src/components/registration/addons-section.tsx @@ -0,0 +1,223 @@ +/** + * Event merch (registration add-ons) section of the registration form. + * + * Renders the organizer's add-on catalog as an optional order bump between + * the coupon input and the fee summary. Entirely skippable — selecting + * nothing changes nothing about the registration flow. + */ +import { Minus, Plus, ShoppingBag } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import type { PublicAddon } from "@/server-fns/competition-addon-fns" +import { getMaxSelectableQuantity } from "@/utils/addon-availability" +import { cn } from "@/utils/cn" +import { formatRegistrationDate } from "./registration-sections" + +/** Stable key for a (product, variant) selection. */ +export function addonSelectionKey( + productId: string, + variantId: string | null, +): string { + return `${productId}::${variantId ?? ""}` +} + +function QuantityStepper({ + value, + max, + disabled, + onChange, + label, +}: { + value: number + max: number + disabled?: boolean + onChange: (next: number) => void + label: string +}) { + return ( +
+ + 0 && "font-semibold", + )} + > + {value} + + +
+ ) +} + +export function AddOnsSection({ + addons, + quantities, + onQuantityChange, + disabled, +}: { + addons: PublicAddon[] + quantities: Map + onQuantityChange: ( + productId: string, + variantId: string | null, + quantity: number, + ) => void + disabled?: boolean +}) { + if (addons.length === 0) return null + + return ( + + + + + Event merch + + + Optional add-ons from the organizer, paid with your registration. Pick + up at the venue. + + + + {addons.map((addon) => { + const quantityForProduct = (variantId: string | null) => + quantities.get(addonSelectionKey(addon.id, variantId)) ?? 0 + const selectedForProduct = + addon.variants.length > 0 + ? addon.variants.reduce( + (sum, v) => sum + quantityForProduct(v.id), + 0, + ) + : quantityForProduct(null) + const productCapReached = + addon.maxPerAthlete !== null && + selectedForProduct >= addon.maxPerAthlete + + return ( +
+
+ {addon.imageUrl ? ( + {addon.name} + ) : null} +
+
+

{addon.name}

+

+ ${(addon.unitChargeCents / 100).toFixed(2)} +

+
+ {addon.description ? ( +

+ {addon.description} +

+ ) : null} +

+ {addon.availableUntil + ? `Order by ${formatRegistrationDate(addon.availableUntil)}` + : "Only available with registration"} + {addon.maxPerAthlete !== null + ? ` · Max ${addon.maxPerAthlete} per athlete` + : ""} +

+
+
+ + {addon.variants.length > 0 ? ( +
+ {addon.variants.map((variant) => { + const quantity = quantityForProduct(variant.id) + const stepperMax = getMaxSelectableQuantity( + addon, + variant.remaining !== null + ? { stockQty: variant.remaining, soldQty: 0 } + : null, + ) + // Freeze increments across variants once the per-product + // cap is hit, while still allowing decrements. + const effectiveMax = + productCapReached && quantity < stepperMax + ? quantity + : stepperMax + return ( +
+ + {variant.label} + {variant.soldOut ? ( + + Sold out + + ) : variant.remaining !== null && + variant.remaining <= 5 ? ( + + {variant.remaining} left + + ) : null} + + + onQuantityChange(addon.id, variant.id, next) + } + /> +
+ ) + })} +
+ ) : ( +
+ + Quantity + + onQuantityChange(addon.id, null, next)} + /> +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/apps/wodsmith-start/src/components/registration/registration-form.tsx b/apps/wodsmith-start/src/components/registration/registration-form.tsx index 092e00669..d51150bc5 100644 --- a/apps/wodsmith-start/src/components/registration/registration-form.tsx +++ b/apps/wodsmith-start/src/components/registration/registration-form.tsx @@ -9,9 +9,11 @@ import type { Team, Waiver, } from "@/db/schema" +import type { PublicAddon } from "@/server-fns/competition-addon-fns" import type { PublicCompetitionDivision } from "@/server-fns/competition-divisions-fns" import type { RegistrationQuestion } from "@/server-fns/registration-questions-fns" import type { CompetitionCapacityResult } from "@/utils/competition-capacity" +import { AddOnsSection } from "./addons-section" import { AffiliateSection, CapacityBanners, @@ -53,6 +55,8 @@ export interface RegistrationFormProps { removedDivisionIds?: string[] previousAnswers?: Array<{ questionId: string; answer: string }> signedWaiverIds?: string[] + /** Purchasable add-ons (merch); empty when none or not entitled */ + addons?: PublicAddon[] } interface PublicProps extends RegistrationFormProps { @@ -149,6 +153,12 @@ export function PublicRegistrationForm(props: PublicProps) { disabled={fieldsDisabled} isApplying={r.isApplyingCoupon} /> + + void activeCoupon: { code: string; amountOffCents: number } | null + addonLineItems?: AddonLineItem[] }) { const isMulti = selectedDivisionIds.length > 1 const hasSelectedDivisions = selectedDivisionIds.length > 0 + const hasAddons = addonLineItems.length > 0 + const addonTotalCents = addonLineItems.reduce( + (sum, item) => sum + item.lineTotalCents, + 0, + ) const selectedFeeValues = selectedDivisionIds .map((divisionId) => divisionFees.get(divisionId)) .filter((fee): fee is number => fee !== undefined) @@ -749,7 +765,11 @@ export function FeeSummarySection({ return ( - Registration Fee{isMulti ? "s" : ""} + + {hasAddons + ? "Order Summary" + : `Registration Fee${isMulti ? "s" : ""}`} + {!hasSelectedDivisions ? ( @@ -761,7 +781,7 @@ export function FeeSummarySection({ ) : null} {selectedDivisionIds.map((divisionId) => { const division = getDivision(divisionId) - const hideDivTotal = isMulti || !!activeCoupon + const hideDivTotal = isMulti || !!activeCoupon || hasAddons return (
{isMulti && ( @@ -781,7 +801,7 @@ export function FeeSummarySection({ {hasSelectedDivisions && hasLoadedSelectedFees ? (() => { const subtotal = selectedFeeValues.reduce((sum, c) => sum + c, 0) - if (!activeCoupon) { + if (!activeCoupon && !hasAddons) { if (!isMulti) return null return (
@@ -792,28 +812,42 @@ export function FeeSummarySection({
) } - const discount = Math.min(activeCoupon.amountOffCents, subtotal) - const total = subtotal - discount + const discount = activeCoupon + ? Math.min(activeCoupon.amountOffCents, subtotal) + : 0 + // Coupons only ever discount registration fees — merch is + // always full price (matches the server's discount base). + const total = subtotal - discount + addonTotalCents return ( <> - {isMulti && ( + {(isMulti || hasAddons) && (
- Subtotal + Registration subtotal ${(subtotal / 100).toFixed(2)}
)} -
- - - Coupon ({activeCoupon.code}) - - -${(discount / 100).toFixed(2)} -
+ {activeCoupon && ( +
+ + + Coupon ({activeCoupon.code}) + + -${(discount / 100).toFixed(2)} +
+ )} + {addonLineItems.map((item) => ( +
+ + {item.name} + {item.variantLabel ? ` (${item.variantLabel})` : ""} + {item.quantity > 1 ? ` × ${item.quantity}` : ""} + + ${(item.lineTotalCents / 100).toFixed(2)} +
+ ))}
Total ${(total / 100).toFixed(2)} diff --git a/apps/wodsmith-start/src/components/registration/use-registration-form.ts b/apps/wodsmith-start/src/components/registration/use-registration-form.ts index bccb8207f..013c86907 100644 --- a/apps/wodsmith-start/src/components/registration/use-registration-form.ts +++ b/apps/wodsmith-start/src/components/registration/use-registration-form.ts @@ -10,6 +10,7 @@ import type { Waiver, } from "@/db/schema" import { trackEvent } from "@/lib/posthog" +import type { PublicAddon } from "@/server-fns/competition-addon-fns" import type { PublicCompetitionDivision } from "@/server-fns/competition-divisions-fns" import { validateCouponForCheckoutFn } from "@/server-fns/coupon-fns" import { initiateRegistrationPaymentFn } from "@/server-fns/registration-fns" @@ -20,6 +21,8 @@ import { getCouponSession, setCouponSession, } from "@/utils/coupon-cookie" +import { addonSelectionKey } from "./addons-section" +import type { AddonLineItem } from "./registration-sections" export interface Teammate { email: string @@ -71,6 +74,11 @@ export interface UseRegistrationFormInput { prefillTeamName?: string /** Logged-in athlete email, used to prevent self-inviting as a teammate. */ userEmail?: string | null + /** + * Purchasable add-ons (merch) for this competition. Empty when the + * organizer has none or lacks the registration_addons entitlement. + */ + addons?: PublicAddon[] } const normalizeEmail = (email: string | null | undefined) => @@ -123,6 +131,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { prefillTeammates = [], prefillTeamName = "", userEmail, + addons = [], } = input const navigate = useNavigate() @@ -198,6 +207,77 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { new Map(), ) + // Add-on (merch) selections, keyed by `${productId}::${variantId ?? ""}`. + const [addonQuantities, setAddonQuantities] = useState>( + new Map(), + ) + + const setAddonQuantity = ( + productId: string, + variantId: string | null, + quantity: number, + ) => { + setAddonQuantities((prev) => { + const next = new Map(prev) + const key = addonSelectionKey(productId, variantId) + if (quantity <= 0) { + next.delete(key) + } else { + next.set(key, quantity) + } + return next + }) + } + + const buildAddonSelections = () => { + const selections: Array<{ + productId: string + variantId?: string + quantity: number + }> = [] + for (const addon of addons) { + if (addon.variants.length > 0) { + for (const variant of addon.variants) { + const quantity = + addonQuantities.get(addonSelectionKey(addon.id, variant.id)) ?? 0 + if (quantity > 0) { + selections.push({ + productId: addon.id, + variantId: variant.id, + quantity, + }) + } + } + } else { + const quantity = + addonQuantities.get(addonSelectionKey(addon.id, null)) ?? 0 + if (quantity > 0) { + selections.push({ productId: addon.id, quantity }) + } + } + } + return selections + } + + const addonLineItems: AddonLineItem[] = buildAddonSelections().map( + (selection) => { + const addon = addons.find((a) => a.id === selection.productId) + const variant = selection.variantId + ? (addon?.variants.find((v) => v.id === selection.variantId) ?? null) + : null + return { + key: addonSelectionKey( + selection.productId, + selection.variantId ?? null, + ), + name: addon?.name ?? "Add-on", + variantLabel: variant?.label ?? null, + quantity: selection.quantity, + lineTotalCents: (addon?.unitChargeCents ?? 0) * selection.quantity, + } + }, + ) + // Prune fee entries for deselected divisions useEffect(() => { setDivisionFees((prev) => { @@ -483,6 +563,21 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { return } + // Add-on caps apply across variants of the same product + const addonSelections = buildAddonSelections() + for (const addon of addons) { + if (addon.maxPerAthlete === null) continue + const total = addonSelections + .filter((s) => s.productId === addon.id) + .reduce((sum, s) => sum + s.quantity, 0) + if (total > addon.maxPerAthlete) { + toast.error( + `Maximum ${addon.maxPerAthlete} per athlete for ${addon.name}`, + ) + return + } + } + setIsSubmitting(true) try { @@ -514,6 +609,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { affiliateName: affiliateName || undefined, answers, couponCode: activeCoupon?.code, + ...(addonSelections.length > 0 ? { addOns: addonSelections } : {}), ...(inviteToken ? { inviteToken } : {}), }, }) @@ -570,6 +666,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { publicDivisions, waivers, questions, + addons, // state isSubmitting, @@ -587,6 +684,8 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { setCouponCodeInput, teamEntries, divisionFees, + addonQuantities, + addonLineItems, answers, agreedWaivers, allRequiredWaiversAgreed, @@ -598,6 +697,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { getDivision, handleDivisionToggle, handleFeesLoaded, + setAddonQuantity, handleApplyCoupon, handleRemoveCoupon, updateTeamEntry, diff --git a/apps/wodsmith-start/src/config/features.ts b/apps/wodsmith-start/src/config/features.ts index 61843ec5d..4503f676c 100644 --- a/apps/wodsmith-start/src/config/features.ts +++ b/apps/wodsmith-start/src/config/features.ts @@ -30,6 +30,7 @@ export const FEATURES = { // Competition platform features HOST_COMPETITIONS: "host_competitions", PRODUCT_COUPONS: "product_coupons", + REGISTRATION_ADDONS: "registration_addons", // `@lat`: [[crew#Crew Billing Catalog]] // Crew event operations features diff --git a/apps/wodsmith-start/src/db/schema.ts b/apps/wodsmith-start/src/db/schema.ts index ecb56d92c..82df32d84 100644 --- a/apps/wodsmith-start/src/db/schema.ts +++ b/apps/wodsmith-start/src/db/schema.ts @@ -1 +1,2 @@ export * from "@repo/wodsmith-db/schema" +export * from "./schemas/competition-products" diff --git a/apps/wodsmith-start/src/db/schemas/competition-products.ts b/apps/wodsmith-start/src/db/schemas/competition-products.ts new file mode 100644 index 000000000..8d3c8710e --- /dev/null +++ b/apps/wodsmith-start/src/db/schemas/competition-products.ts @@ -0,0 +1,124 @@ +import type { InferSelectModel } from "drizzle-orm" +import { relations } from "drizzle-orm" +import { index, int, mysqlTable, text, varchar } from "drizzle-orm/mysql-core" +import { + commonColumns, + createCompetitionProductId, + createCompetitionProductVariantId, +} from "./common" +import { competitionsTable } from "./competitions" + +// Competition product status +export const COMPETITION_PRODUCT_STATUS = { + ACTIVE: "ACTIVE", + HIDDEN: "HIDDEN", + ARCHIVED: "ARCHIVED", +} as const + +export type CompetitionProductStatus = + (typeof COMPETITION_PRODUCT_STATUS)[keyof typeof COMPETITION_PRODUCT_STATUS] + +/** + * Competition Products Table (registration add-ons / merch catalog) + * + * Organizer-defined products sold during competition registration + * (e.g., event t-shirts). Purchases reference these through a lazily + * created commerce_products row (type=ADDON, resourceId=). + * Gated behind the `registration_addons` team feature entitlement. + */ +export const competitionProductsTable = mysqlTable( + "competition_products", + { + ...commonColumns, + id: varchar({ length: 255 }) + .primaryKey() + .$defaultFn(() => createCompetitionProductId()) + .notNull(), + // The competition this product is sold for + competitionId: varchar({ length: 255 }).notNull(), + // Display name (e.g., "Event Tee 2026") + name: varchar({ length: 255 }).notNull(), + // Optional markdown/plain description shown in the registration form + description: text(), + // Optional product image + imageUrl: varchar({ length: 1024 }), + // Price per unit in cents (variants share the product price in v1) + priceCents: int().notNull(), + // Max quantity a single registrant can order (null = no cap) + maxPerAthlete: int(), + // Order-by deadline as YYYY-MM-DD, evaluated end-of-day in the + // competition's IANA timezone (same semantics as registrationClosesAt). + // Null = available while registration is open. + availableUntil: varchar({ length: 10 }), + // ACTIVE = purchasable, HIDDEN = organizer kill switch, ARCHIVED = soft delete + status: varchar({ length: 20 }) + .$type() + .notNull() + .default(COMPETITION_PRODUCT_STATUS.ACTIVE), + // Display order in the registration form + sortOrder: int().notNull().default(0), + }, + (table) => [ + index("competition_products_competition_idx").on(table.competitionId), + ], +) + +/** + * Competition Product Variants Table (e.g., t-shirt sizes) + * + * Each variant can optionally track stock. soldQty is incremented + * atomically by the Stripe checkout workflow when a purchase completes; + * stockQty null = untracked inventory (deadline-only availability). + * Products with zero variants are sold without a variant selection. + */ +export const competitionProductVariantsTable = mysqlTable( + "competition_product_variants", + { + ...commonColumns, + id: varchar({ length: 255 }) + .primaryKey() + .$defaultFn(() => createCompetitionProductVariantId()) + .notNull(), + productId: varchar({ length: 255 }).notNull(), + // Variant label shown to athletes (e.g., "S", "M", "L", "XL") + label: varchar({ length: 100 }).notNull(), + // Max units sellable (null = untracked) + stockQty: int(), + // Units sold via COMPLETED purchases (authoritative counter) + soldQty: int().notNull().default(0), + sortOrder: int().notNull().default(0), + }, + (table) => [ + index("competition_product_variants_product_idx").on(table.productId), + ], +) + +// Type exports +export type CompetitionProduct = InferSelectModel< + typeof competitionProductsTable +> +export type CompetitionProductVariant = InferSelectModel< + typeof competitionProductVariantsTable +> + +// Relations +export const competitionProductsRelations = relations( + competitionProductsTable, + ({ one, many }) => ({ + competition: one(competitionsTable, { + fields: [competitionProductsTable.competitionId], + references: [competitionsTable.id], + }), + variants: many(competitionProductVariantsTable), + }), +) + +export const competitionProductVariantsRelations = relations( + competitionProductVariantsTable, + ({ one }) => ({ + product: one(competitionProductsTable, { + fields: [competitionProductVariantsTable.productId], + references: [competitionProductsTable.id], + }), + }), +) diff --git a/apps/wodsmith-start/src/routeTree.gen.ts b/apps/wodsmith-start/src/routeTree.gen.ts index ec60e911d..27efcac41 100644 --- a/apps/wodsmith-start/src/routeTree.gen.ts +++ b/apps/wodsmith-start/src/routeTree.gen.ts @@ -121,6 +121,7 @@ import { Route as CompeteOrganizerCompetitionIdScheduleRouteImport } from './rou import { Route as CompeteOrganizerCompetitionIdRevenueRouteImport } from './routes/compete/organizer/$competitionId/revenue' import { Route as CompeteOrganizerCompetitionIdResultsRouteImport } from './routes/compete/organizer/$competitionId/results' import { Route as CompeteOrganizerCompetitionIdPricingRouteImport } from './routes/compete/organizer/$competitionId/pricing' +import { Route as CompeteOrganizerCompetitionIdMerchRouteImport } from './routes/compete/organizer/$competitionId/merch' import { Route as CompeteOrganizerCompetitionIdLocationsRouteImport } from './routes/compete/organizer/$competitionId/locations' import { Route as CompeteOrganizerCompetitionIdLeaderboardPreviewRouteImport } from './routes/compete/organizer/$competitionId/leaderboard-preview' import { Route as CompeteOrganizerCompetitionIdJudgesAiRouteImport } from './routes/compete/organizer/$competitionId/judges-ai' @@ -823,6 +824,12 @@ const CompeteOrganizerCompetitionIdPricingRoute = path: '/pricing', getParentRoute: () => CompeteOrganizerCompetitionIdRoute, } as any) +const CompeteOrganizerCompetitionIdMerchRoute = + CompeteOrganizerCompetitionIdMerchRouteImport.update({ + id: '/merch', + path: '/merch', + getParentRoute: () => CompeteOrganizerCompetitionIdRoute, + } as any) const CompeteOrganizerCompetitionIdLocationsRoute = CompeteOrganizerCompetitionIdLocationsRouteImport.update({ id: '/locations', @@ -1534,6 +1541,7 @@ export interface FileRoutesByFullPath { '/compete/organizer/$competitionId/judges-ai': typeof CompeteOrganizerCompetitionIdJudgesAiRoute '/compete/organizer/$competitionId/leaderboard-preview': typeof CompeteOrganizerCompetitionIdLeaderboardPreviewRoute '/compete/organizer/$competitionId/locations': typeof CompeteOrganizerCompetitionIdLocationsRoute + '/compete/organizer/$competitionId/merch': typeof CompeteOrganizerCompetitionIdMerchRoute '/compete/organizer/$competitionId/pricing': typeof CompeteOrganizerCompetitionIdPricingRoute '/compete/organizer/$competitionId/results': typeof CompeteOrganizerCompetitionIdResultsRoute '/compete/organizer/$competitionId/revenue': typeof CompeteOrganizerCompetitionIdRevenueRoute @@ -1736,6 +1744,7 @@ export interface FileRoutesByTo { '/compete/organizer/$competitionId/judges-ai': typeof CompeteOrganizerCompetitionIdJudgesAiRoute '/compete/organizer/$competitionId/leaderboard-preview': typeof CompeteOrganizerCompetitionIdLeaderboardPreviewRoute '/compete/organizer/$competitionId/locations': typeof CompeteOrganizerCompetitionIdLocationsRoute + '/compete/organizer/$competitionId/merch': typeof CompeteOrganizerCompetitionIdMerchRoute '/compete/organizer/$competitionId/pricing': typeof CompeteOrganizerCompetitionIdPricingRoute '/compete/organizer/$competitionId/results': typeof CompeteOrganizerCompetitionIdResultsRoute '/compete/organizer/$competitionId/revenue': typeof CompeteOrganizerCompetitionIdRevenueRoute @@ -1943,6 +1952,7 @@ export interface FileRoutesById { '/compete/organizer/$competitionId/judges-ai': typeof CompeteOrganizerCompetitionIdJudgesAiRoute '/compete/organizer/$competitionId/leaderboard-preview': typeof CompeteOrganizerCompetitionIdLeaderboardPreviewRoute '/compete/organizer/$competitionId/locations': typeof CompeteOrganizerCompetitionIdLocationsRoute + '/compete/organizer/$competitionId/merch': typeof CompeteOrganizerCompetitionIdMerchRoute '/compete/organizer/$competitionId/pricing': typeof CompeteOrganizerCompetitionIdPricingRoute '/compete/organizer/$competitionId/results': typeof CompeteOrganizerCompetitionIdResultsRoute '/compete/organizer/$competitionId/revenue': typeof CompeteOrganizerCompetitionIdRevenueRoute @@ -2155,6 +2165,7 @@ export interface FileRouteTypes { | '/compete/organizer/$competitionId/judges-ai' | '/compete/organizer/$competitionId/leaderboard-preview' | '/compete/organizer/$competitionId/locations' + | '/compete/organizer/$competitionId/merch' | '/compete/organizer/$competitionId/pricing' | '/compete/organizer/$competitionId/results' | '/compete/organizer/$competitionId/revenue' @@ -2357,6 +2368,7 @@ export interface FileRouteTypes { | '/compete/organizer/$competitionId/judges-ai' | '/compete/organizer/$competitionId/leaderboard-preview' | '/compete/organizer/$competitionId/locations' + | '/compete/organizer/$competitionId/merch' | '/compete/organizer/$competitionId/pricing' | '/compete/organizer/$competitionId/results' | '/compete/organizer/$competitionId/revenue' @@ -2563,6 +2575,7 @@ export interface FileRouteTypes { | '/compete/organizer/$competitionId/judges-ai' | '/compete/organizer/$competitionId/leaderboard-preview' | '/compete/organizer/$competitionId/locations' + | '/compete/organizer/$competitionId/merch' | '/compete/organizer/$competitionId/pricing' | '/compete/organizer/$competitionId/results' | '/compete/organizer/$competitionId/revenue' @@ -3483,6 +3496,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CompeteOrganizerCompetitionIdPricingRouteImport parentRoute: typeof CompeteOrganizerCompetitionIdRoute } + '/compete/organizer/$competitionId/merch': { + id: '/compete/organizer/$competitionId/merch' + path: '/merch' + fullPath: '/compete/organizer/$competitionId/merch' + preLoaderRoute: typeof CompeteOrganizerCompetitionIdMerchRouteImport + parentRoute: typeof CompeteOrganizerCompetitionIdRoute + } '/compete/organizer/$competitionId/locations': { id: '/compete/organizer/$competitionId/locations' path: '/locations' @@ -4627,6 +4647,7 @@ interface CompeteOrganizerCompetitionIdRouteChildren { CompeteOrganizerCompetitionIdJudgesAiRoute: typeof CompeteOrganizerCompetitionIdJudgesAiRoute CompeteOrganizerCompetitionIdLeaderboardPreviewRoute: typeof CompeteOrganizerCompetitionIdLeaderboardPreviewRoute CompeteOrganizerCompetitionIdLocationsRoute: typeof CompeteOrganizerCompetitionIdLocationsRoute + CompeteOrganizerCompetitionIdMerchRoute: typeof CompeteOrganizerCompetitionIdMerchRoute CompeteOrganizerCompetitionIdPricingRoute: typeof CompeteOrganizerCompetitionIdPricingRoute CompeteOrganizerCompetitionIdResultsRoute: typeof CompeteOrganizerCompetitionIdResultsRoute CompeteOrganizerCompetitionIdRevenueRoute: typeof CompeteOrganizerCompetitionIdRevenueRoute @@ -4676,6 +4697,8 @@ const CompeteOrganizerCompetitionIdRouteChildren: CompeteOrganizerCompetitionIdR CompeteOrganizerCompetitionIdLeaderboardPreviewRoute, CompeteOrganizerCompetitionIdLocationsRoute: CompeteOrganizerCompetitionIdLocationsRoute, + CompeteOrganizerCompetitionIdMerchRoute: + CompeteOrganizerCompetitionIdMerchRoute, CompeteOrganizerCompetitionIdPricingRoute: CompeteOrganizerCompetitionIdPricingRoute, CompeteOrganizerCompetitionIdResultsRoute: diff --git a/apps/wodsmith-start/src/routes/compete/$slug/register.tsx b/apps/wodsmith-start/src/routes/compete/$slug/register.tsx index 0a7cef0e3..97756644a 100644 --- a/apps/wodsmith-start/src/routes/compete/$slug/register.tsx +++ b/apps/wodsmith-start/src/routes/compete/$slug/register.tsx @@ -23,6 +23,7 @@ import { userTable, waiverSignaturesTable, } from "@/db/schema" +import { getPublicCompetitionAddonsFn } from "@/server-fns/competition-addon-fns" import { getPublicCompetitionDivisionsFn, parseCompetitionSettings, @@ -342,6 +343,7 @@ export const Route = createFileRoute("/compete/$slug/register")({ { waivers }, { questions }, invitePrefill, + { addons }, ] = await Promise.all([ getUserCompetitionRegistrationsFn({ data: { @@ -361,6 +363,9 @@ export const Route = createFileRoute("/compete/$slug/register")({ inviteToken ? getInvitePrefillFn({ data: { slug, token: inviteToken } }) : Promise.resolve({ priorTeam: null as null }), + getPublicCompetitionAddonsFn({ + data: { competitionId: competition.id }, + }), ]) // Invite-flow short-circuit: if the URL specifies a division (the claim @@ -419,6 +424,7 @@ export const Route = createFileRoute("/compete/$slug/register")({ previousAnswers: [], signedWaiverIds: [], invitePriorTeam: null, + addons: [], } } @@ -460,6 +466,7 @@ export const Route = createFileRoute("/compete/$slug/register")({ previousAnswers: [], signedWaiverIds: [], invitePriorTeam: null, + addons: [], } } @@ -484,6 +491,7 @@ export const Route = createFileRoute("/compete/$slug/register")({ previousAnswers, signedWaiverIds, invitePriorTeam: invitePrefill.priorTeam, + addons, } }, }) @@ -510,6 +518,7 @@ function RegisterPage() { previousAnswers, signedWaiverIds, invitePriorTeam, + addons, } = Route.useLoaderData() const { @@ -561,6 +570,7 @@ function RegisterPage() { removedDivisionIds, previousAnswers, signedWaiverIds, + addons, } return ( diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId.tsx index fb68b114d..a18a48af6 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId.tsx @@ -89,6 +89,7 @@ const routeLabels: Record = { pricing: "Pricing", revenue: "Revenue", coupons: "Coupons", + merch: "Merch", sponsors: "Sponsors", settings: "Settings", edit: "Competition details", diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx new file mode 100644 index 000000000..537a80751 --- /dev/null +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx @@ -0,0 +1,774 @@ +/** + * Competition Merch (Registration Add-ons) Route + * + * Organizers manage the add-on catalog sold inside the registration flow + * (e.g., event tees with sizes), plus fulfillment reports: counts-by-variant + * for the print shop and the per-athlete pickup list for the check-in table. + * + * Selling add-ons requires the `registration_addons` entitlement, granted + * per organizing team by platform admins (/admin/entitlements). Without it + * this page renders a locked state and all mutations are rejected server-side. + */ +// @lat: [[organizer-dashboard#Merch]] + +import { createFileRoute } from "@tanstack/react-router" +import { useServerFn } from "@tanstack/react-start" +import { + Archive, + Eye, + EyeOff, + Lock, + Pencil, + Plus, + ShoppingBag, + Trash2, +} from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" +import { COMPETITION_PRODUCT_STATUS } from "@/db/schemas/competition-products" +import { + archiveCompetitionAddonFn, + createCompetitionAddonFn, + getAddonSalesReportFn, + listCompetitionAddonsFn, + type OrganizerAddon, + updateCompetitionAddonFn, +} from "@/server-fns/competition-addon-fns" + +export const Route = createFileRoute("/compete/organizer/$competitionId/merch")( + { + component: MerchPage, + loader: async ({ parentMatchPromise }) => { + const parentMatch = await parentMatchPromise + const { competition } = parentMatch.loaderData! + + const [{ entitled, addons }, report] = await Promise.all([ + listCompetitionAddonsFn({ + data: { + competitionId: competition.id, + teamId: competition.organizingTeamId, + }, + }), + getAddonSalesReportFn({ + data: { + competitionId: competition.id, + teamId: competition.organizingTeamId, + }, + }), + ]) + + return { competition, entitled, addons, report } + }, + }, +) + +interface VariantDraft { + id?: string + label: string + stock: string + unitsSold: number +} + +interface AddonDraft { + name: string + priceDollars: string + description: string + imageUrl: string + availableUntil: string + maxPerAthlete: string + status: string + variants: VariantDraft[] +} + +const emptyDraft: AddonDraft = { + name: "", + priceDollars: "", + description: "", + imageUrl: "", + availableUntil: "", + maxPerAthlete: "", + status: COMPETITION_PRODUCT_STATUS.ACTIVE, + variants: [], +} + +function draftFromAddon(addon: OrganizerAddon): AddonDraft { + return { + name: addon.name, + priceDollars: (addon.priceCents / 100).toFixed(2), + description: addon.description ?? "", + imageUrl: addon.imageUrl ?? "", + availableUntil: addon.availableUntil ?? "", + maxPerAthlete: addon.maxPerAthlete?.toString() ?? "", + status: addon.status, + variants: addon.variants.map((v) => ({ + id: v.id, + label: v.label, + stock: v.stockQty?.toString() ?? "", + unitsSold: v.unitsSold, + })), + } +} + +function StatusBadge({ status }: { status: string }) { + if (status === COMPETITION_PRODUCT_STATUS.ACTIVE) + return ( + + Active + + ) + if (status === COMPETITION_PRODUCT_STATUS.HIDDEN) + return ( + + Hidden + + ) + return Archived +} + +function MerchPage() { + const { + competition, + entitled, + addons: initialAddons, + report: initialReport, + } = Route.useLoaderData() + + const teamId = competition.organizingTeamId + const competitionId = competition.id + + const [addons, setAddons] = useState(initialAddons) + const [report, setReport] = useState(initialReport) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [draft, setDraft] = useState(emptyDraft) + const [isSaving, setIsSaving] = useState(false) + + const createAddon = useServerFn(createCompetitionAddonFn) + const updateAddon = useServerFn(updateCompetitionAddonFn) + const archiveAddon = useServerFn(archiveCompetitionAddonFn) + + async function refresh() { + const [{ addons: nextAddons }, nextReport] = await Promise.all([ + listCompetitionAddonsFn({ data: { competitionId, teamId } }), + getAddonSalesReportFn({ data: { competitionId, teamId } }), + ]) + setAddons(nextAddons) + setReport(nextReport) + } + + if (!entitled) { + return ( +
+
+

Merch

+

+ Sell event merch during registration for {competition.name} +

+
+ + + +
+

+ Registration add-ons aren't enabled for your account +

+

+ Sell t-shirts and other merch inside your registration flow, + with size options, order deadlines, and fulfillment reports. + Contact WODsmith to enable this feature for your team. +

+
+
+
+
+ ) + } + + function openCreate() { + setEditingId(null) + setDraft(emptyDraft) + setDialogOpen(true) + } + + function openEdit(addon: OrganizerAddon) { + setEditingId(addon.id) + setDraft(draftFromAddon(addon)) + setDialogOpen(true) + } + + function updateVariant(index: number, patch: Partial) { + setDraft((prev) => ({ + ...prev, + variants: prev.variants.map((v, i) => + i === index ? { ...v, ...patch } : v, + ), + })) + } + + async function handleSave() { + const priceCents = Math.round(parseFloat(draft.priceDollars) * 100) + if (!draft.name.trim()) { + toast.error("Enter a product name") + return + } + if (Number.isNaN(priceCents) || priceCents <= 0) { + toast.error("Enter a valid price") + return + } + const variants = draft.variants + .filter((v) => v.label.trim()) + .map((v) => ({ + ...(v.id ? { id: v.id } : {}), + label: v.label.trim(), + stockQty: v.stock.trim() === "" ? null : parseInt(v.stock, 10), + })) + if (variants.some((v) => v.stockQty !== null && Number.isNaN(v.stockQty))) { + toast.error("Stock must be a number (or blank for untracked)") + return + } + + const shared = { + name: draft.name.trim(), + description: draft.description.trim() || undefined, + imageUrl: draft.imageUrl.trim() || undefined, + priceCents, + maxPerAthlete: draft.maxPerAthlete.trim() + ? parseInt(draft.maxPerAthlete, 10) + : null, + availableUntil: draft.availableUntil.trim() || null, + status: draft.status as "ACTIVE" | "HIDDEN" | "ARCHIVED", + variants, + } + + setIsSaving(true) + try { + if (editingId) { + await updateAddon({ + data: { productId: editingId, teamId, ...shared }, + }) + toast.success("Add-on updated") + } else { + await createAddon({ + data: { competitionId, teamId, ...shared }, + }) + toast.success("Add-on created") + } + setDialogOpen(false) + await refresh() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to save add-on") + } finally { + setIsSaving(false) + } + } + + async function handleToggleVisibility(addon: OrganizerAddon) { + const nextStatus = + addon.status === COMPETITION_PRODUCT_STATUS.ACTIVE + ? COMPETITION_PRODUCT_STATUS.HIDDEN + : COMPETITION_PRODUCT_STATUS.ACTIVE + try { + await updateAddon({ + data: { productId: addon.id, teamId, status: nextStatus }, + }) + toast.success( + nextStatus === COMPETITION_PRODUCT_STATUS.ACTIVE + ? "Add-on is now visible to athletes" + : "Add-on hidden from athletes", + ) + await refresh() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to update") + } + } + + async function handleArchive(addonId: string) { + try { + await archiveAddon({ data: { productId: addonId, teamId } }) + toast.success("Add-on archived") + await refresh() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to archive") + } + } + + return ( +
+
+
+

Merch

+

+ Sell add-ons inside the registration flow for {competition.name}. + Athletes pay with their registration; pickup happens at the venue. +

+
+ +
+ + {/* Catalog */} + + + + + Products ({addons.length}) + + + + {addons.length === 0 ? ( +
+ +

No merch yet

+

+ Add a product to offer it during registration +

+
+ ) : ( +
+ + + + Product + Price + Options + Order by + Status + Sold + Revenue + Actions + + + + {addons.map((addon) => ( + + + {addon.name} + + + ${(addon.priceCents / 100).toFixed(2)} + + + {addon.variants.length === 0 ? ( + + ) : ( +
+ {addon.variants.map((v) => ( + + {v.label} + + {v.stockQty !== null + ? `${v.unitsSold}/${v.stockQty}` + : v.unitsSold} + + + ))} +
+ )} +
+ {addon.availableUntil ?? "—"} + + + + + {addon.unitsSold} + + + ${(addon.revenueCents / 100).toFixed(2)} + + +
+ + {addon.status !== + COMPETITION_PRODUCT_STATUS.ARCHIVED && ( + <> + + + + )} +
+
+
+ ))} +
+
+
+ )} +
+
+ + {/* Fulfillment: counts by variant */} + + + + Print shop summary (counts by option) + + + + {report.variantCounts.length === 0 ? ( +

+ No completed sales yet +

+ ) : ( + + + + Product + Option + Units + Revenue + + + + {report.variantCounts.map((row) => ( + + {row.productName} + {row.variantLabel ?? "—"} + {row.units} + + ${(row.revenueCents / 100).toFixed(2)} + + + ))} + +
+ )} +
+
+ + {/* Fulfillment: pickup list */} + + + Pickup list + + + {report.pickupList.length === 0 ? ( +

+ No completed sales yet +

+ ) : ( + + + + Athlete + Email + Item + Qty + + + + {report.pickupList.map((row) => ( + + + {row.purchaserName} + + + {row.purchaserEmail ?? "—"} + + + {row.productName} + {row.variantLabel ? ` (${row.variantLabel})` : ""} + + {row.quantity} + + ))} + +
+ )} +
+
+ + {/* Create / edit dialog */} + + + + + {editingId ? "Edit add-on" : "New add-on"} + + +
+
+ + + setDraft((d) => ({ ...d, name: e.target.value })) + } + /> +
+
+
+ + + setDraft((d) => ({ ...d, priceDollars: e.target.value })) + } + /> +
+
+ + + setDraft((d) => ({ ...d, maxPerAthlete: e.target.value })) + } + /> +
+
+
+
+ + + setDraft((d) => ({ ...d, availableUntil: e.target.value })) + } + /> +

+ Last day athletes can order — e.g. your print shop deadline. + End of day in the competition timezone. +

+
+
+ + +
+
+
+ +