diff --git a/apps/docs/docs/how-to/organizers/sell-merch.md b/apps/docs/docs/how-to/organizers/sell-merch.md new file mode 100644 index 000000000..ef8709e70 --- /dev/null +++ b/apps/docs/docs/how-to/organizers/sell-merch.md @@ -0,0 +1,72 @@ +--- +sidebar_position: 6 +--- + +# How to Sell Merch During Registration + +Sell event t-shirts and other add-ons inside your registration flow. Athletes pick items while registering and pay for everything in one checkout; you hand the merch out at the venue. + +## Prerequisites + +- Competition organizer permissions +- Registration add-ons enabled for your team (this is an account-level feature — contact WODsmith to turn it on) +- A verified Stripe account connected to your team (merch is always paid) + +If add-ons aren't enabled, the Merch page shows a locked notice instead of the editor. + +## Adding a Product + +1. Open your competition from the **Organizer** dashboard +2. Click **Merch** in the sidebar (under Business) +3. Click **Add product** +4. Fill in the product details: + - **Name** — what athletes see (e.g., "Event Tee 2026") + - **Price ($)** — your price per unit; processing fees are added on top according to your competition's fee settings + - **Max per athlete** *(optional)* — caps how many one registrant can order across all sizes + - **Order by** *(optional)* — last day athletes can order, end of day in your competition's timezone + - **Description** and **Image URL** *(optional)* +5. Add **Options** if the product comes in sizes (e.g., S, M, L, XL) +6. Click **Create add-on** + +To sell a shirt that's *included* in the registration fee, don't use Merch — collect sizes with a [registration question](/how-to/organizers/registration-questions) instead. Use Merch when athletes pay extra for the item. + +## Controlling Availability + +Pick the model that matches how you source the merch: + +- **Ordering from a print shop after registration?** Set **Order by** to your print deadline and leave each option's **Stock** blank. Athletes can order any quantity until the cutoff, and your final counts go to the printer. +- **Selling fixed inventory you already have?** Set **Stock** per option. Sold-out sizes are disabled automatically, and the rare order that slips through during simultaneous checkouts is refunded automatically. +- You can combine both: "order by June 1, while supplies last." + +## How Athletes Buy + +Athletes see an **Event merch** section in the registration form, between the coupon field and the order summary. They pick a size and quantity, and the items are added to the same Stripe checkout as their registration fee. + +- Merch works with free divisions too — a $0 registration with a paid shirt still goes through checkout. +- Coupons never discount merch; codes apply to registration fees only. +- Only registrants can buy. There is no standalone store. + +## Hiding, Editing, and Archiving + +From the products table on the Merch page: + +- Click the **eye icon** to hide a product from athletes without losing it (e.g., while you fix a price) +- Click the **pencil icon** to edit details, sizes, and stock +- Click the **archive icon** to retire a product; its sales history stays in your reports + +Sizes that have sold units can't be removed — set their stock to 0 instead. + +## Fulfilling Orders + +The Merch page gives you both reports you need: + +- **Print shop summary** — total units per product and size. Send this to your printer after the order deadline passes. +- **Pickup list** — every athlete with the items and quantities they bought. Use it at the check-in table on event day. + +## Refunds + +If a size oversells during simultaneous checkouts, that merch line is refunded automatically and the athlete's registration is unaffected. For other refunds, issue the exact partial amount from Stripe. Multi-item dashboard refunds may need manual reconciliation in WODsmith because Stripe does not identify which checkout line was refunded. + +--- + +*See also: [How to Manage Registrations](/how-to/organizers/manage-registrations) · [How to Create Registration Questions](/how-to/organizers/registration-questions)* diff --git a/apps/docs/docs/tutorials/athletes/first-competition.md b/apps/docs/docs/tutorials/athletes/first-competition.md index 8c198785f..3c2c91ed1 100644 --- a/apps/docs/docs/tutorials/athletes/first-competition.md +++ b/apps/docs/docs/tutorials/athletes/first-competition.md @@ -74,6 +74,8 @@ Fill in your registration details: Required fields are marked with `*`. The form won't submit until they're all complete. +**Notice** that some competitions also show an **Event merch** section — optional extras like an event t-shirt the organizer sells alongside registration. Pick a size and quantity if you want one (it's added to the same payment, and you collect it at the venue), or skip it entirely. + ## Step 5: Sign the Waiver Most competitions require an electronic waiver before paying. diff --git a/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts b/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts index 7d281183e..139b6b6c7 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 Gate]] + { + 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..930d3b00e --- /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.priceCents / 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/fee-breakdown.tsx b/apps/wodsmith-start/src/components/registration/fee-breakdown.tsx index ec45bf21f..4daa87cd4 100644 --- a/apps/wodsmith-start/src/components/registration/fee-breakdown.tsx +++ b/apps/wodsmith-start/src/components/registration/fee-breakdown.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge" import { Skeleton } from "@/components/ui/skeleton" import { getRegistrationFeeBreakdownFn } from "@/server-fns/registration-fns" -type FeeData = { +export type FeeData = { isFree: boolean registrationFeeCents?: number platformFeeCents?: number @@ -12,6 +12,7 @@ type FeeData = { totalChargeCents?: number stripeFeesPassedToCustomer?: boolean platformFeesPassedToCustomer?: boolean + feeConfig?: import("@/server/commerce/fee-calculator").FeeConfiguration } type FeeBreakdownProps = { @@ -19,6 +20,8 @@ type FeeBreakdownProps = { divisionId: string | null /** Hide the per-division total line (when showing a combined total externally) */ hideTotal?: boolean + /** Combined summaries render transaction-level fees once below all lines. */ + hideFees?: boolean /** Report loaded fee data to parent */ onFeesLoaded?: (divisionId: string, fees: FeeData | null) => void } @@ -27,6 +30,7 @@ export function FeeBreakdown({ competitionId, divisionId, hideTotal, + hideFees, onFeesLoaded, }: FeeBreakdownProps) { const [fees, setFees] = useState(null) @@ -101,18 +105,20 @@ export function FeeBreakdown({ {formatCents(fees.registrationFeeCents ?? 0)} - {fees.platformFeeCents != null && fees.platformFeeCents > 0 && ( -
- - Platform Fee - {!fees.platformFeesPassedToCustomer && ( - (included) - )} - - {formatCents(fees.platformFeeCents)} -
- )} - {fees.stripeFeeCents != null && fees.stripeFeeCents > 0 && ( + {!hideFees && + fees.platformFeeCents != null && + fees.platformFeeCents > 0 && ( +
+ + Platform Fee + {!fees.platformFeesPassedToCustomer && ( + (included) + )} + + {formatCents(fees.platformFeeCents)} +
+ )} + {!hideFees && fees.stripeFeeCents != null && fees.stripeFeeCents > 0 && (
Processing Fee 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} /> + + ScalingLevel | undefined - divisionFees: Map + divisionFees: Map onFeesLoaded: ( divisionId: string, fees: { isFree: boolean; totalChargeCents?: number } | null, ) => 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 selectedFeeValues = selectedDivisionIds .map((divisionId) => divisionFees.get(divisionId)) - .filter((fee): fee is number => fee !== undefined) + .filter((fee): fee is FeeData => fee !== undefined) const hasLoadedSelectedFees = selectedFeeValues.length === selectedDivisionIds.length return ( - Registration Fee{isMulti ? "s" : ""} + + {hasAddons + ? "Order Summary" + : `Registration Fee${isMulti ? "s" : ""}`} + {!hasSelectedDivisions ? ( @@ -761,7 +780,7 @@ export function FeeSummarySection({ ) : null} {selectedDivisionIds.map((divisionId) => { const division = getDivision(divisionId) - const hideDivTotal = isMulti || !!activeCoupon + const hideDivTotal = isMulti || !!activeCoupon || hasAddons return (
{isMulti && ( @@ -773,6 +792,7 @@ export function FeeSummarySection({ competitionId={competitionId} divisionId={divisionId} hideTotal={hideDivTotal} + hideFees={hideDivTotal} onFeesLoaded={onFeesLoaded} />
@@ -780,43 +800,124 @@ export function FeeSummarySection({ })} {hasSelectedDivisions && hasLoadedSelectedFees ? (() => { - const subtotal = selectedFeeValues.reduce((sum, c) => sum + c, 0) - if (!activeCoupon) { + const registrationBaseCents = selectedFeeValues.reduce( + (sum, fees) => sum + (fees.registrationFeeCents ?? 0), + 0, + ) + if (!activeCoupon && !hasAddons) { if (!isMulti) return null + } + const discount = activeCoupon + ? Math.min(activeCoupon.amountOffCents, registrationBaseCents) + : 0 + const feeConfig = + selectedFeeValues.find((fees) => fees.feeConfig)?.feeConfig ?? + addonLineItems.find((item) => item.feeConfig)?.feeConfig + if (!feeConfig) { + const fallbackTotal = selectedFeeValues.reduce( + (sum, fees) => sum + (fees.totalChargeCents ?? 0), + 0, + ) return (
Total - ${(subtotal / 100).toFixed(2)} + ${(fallbackTotal / 100).toFixed(2)}
) } - const discount = Math.min(activeCoupon.amountOffCents, subtotal) - const total = subtotal - discount + const registrationDiscounts = allocateCents( + discount, + selectedFeeValues.map((fees) => fees.registrationFeeCents ?? 0), + ) + const checkout = calculateCheckoutFees( + [ + ...selectedFeeValues.map((fees, index) => ({ + key: `registration:${selectedDivisionIds[index]}`, + basePriceCents: fees.registrationFeeCents ?? 0, + discountCents: registrationDiscounts[index] ?? 0, + platformFixedCents: + (fees.registrationFeeCents ?? 0) === 0 ? 0 : undefined, + })), + ...addonLineItems.map((item) => ({ + key: item.key, + basePriceCents: item.lineTotalCents, + platformFixedCents: 0, + })), + ], + feeConfig, + ) + const checkoutLines = new Map( + checkout.lines.map((line) => [line.key, line]), + ) return ( <> - {isMulti && ( + {(isMulti || hasAddons) && (
- Subtotal - ${(subtotal / 100).toFixed(2)} + Registration subtotal + ${(registrationBaseCents / 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}` : ""} + + + $ + {( + (checkoutLines.get(item.key)?.registrationFeeCents ?? + item.lineTotalCents) / 100 + ).toFixed(2)} + +
+ ))} + {checkout.totalPlatformFeeCents > 0 && ( +
+ + Platform fee + {!feeConfig.passPlatformFeesToCustomer + ? " (included)" + : ""} + + + ${(checkout.totalPlatformFeeCents / 100).toFixed(2)} + +
+ )} + {checkout.totalStripeFeeCents > 0 && ( +
+ + Processing fee + {!feeConfig.passStripeFeesToCustomer + ? " (included)" + : ""} + + + ${(checkout.totalStripeFeeCents / 100).toFixed(2)} +
)} -
- - - Coupon ({activeCoupon.code}) - - -${(discount / 100).toFixed(2)} -
Total - ${(total / 100).toFixed(2)} + + ${(checkout.totalChargeCents / 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..d650fac48 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,9 @@ import { getCouponSession, setCouponSession, } from "@/utils/coupon-cookie" +import { addonSelectionKey } from "./addons-section" +import type { FeeData } from "./fee-breakdown" +import type { AddonLineItem } from "./registration-sections" export interface Teammate { email: string @@ -71,6 +75,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 +132,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { prefillTeammates = [], prefillTeamName = "", userEmail, + addons = [], } = input const navigate = useNavigate() @@ -194,10 +204,82 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { return next }) - const [divisionFees, setDivisionFees] = useState>( + const [divisionFees, setDivisionFees] = useState>( + 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?.priceCents ?? 0) * selection.quantity, + feeConfig: addon?.feeConfig, + } + }, + ) + // Prune fee entries for deselected divisions useEffect(() => { setDivisionFees((prev) => { @@ -207,7 +289,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { if (!selectedSet.has(key)) changed = true } if (!changed) return prev - const next = new Map() + const next = new Map() for (const [k, v] of prev) { if (selectedSet.has(k)) next.set(k, v) } @@ -215,14 +297,14 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { }) }, [selectedDivisionIds]) - const handleFeesLoaded = ( - divisionId: string, - fees: { isFree: boolean; totalChargeCents?: number } | null, - ) => { + const handleFeesLoaded = (divisionId: string, fees: FeeData | null) => { setDivisionFees((prev) => { const next = new Map(prev) - if (fees && !fees.isFree && fees.totalChargeCents) { - next.set(divisionId, fees.totalChargeCents) + if (fees) { + // Track free divisions as $0 rather than absent — the fee summary + // gates its totals (and add-on lines) on every selected division + // having reported, so a free division must still count as loaded. + next.set(divisionId, fees) } else { next.delete(divisionId) } @@ -483,6 +565,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 +611,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { affiliateName: affiliateName || undefined, answers, couponCode: activeCoupon?.code, + ...(addonSelections.length > 0 ? { addOns: addonSelections } : {}), ...(inviteToken ? { inviteToken } : {}), }, }) @@ -570,6 +668,7 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { publicDivisions, waivers, questions, + addons, // state isSubmitting, @@ -587,6 +686,8 @@ export function useRegistrationForm(input: UseRegistrationFormInput) { setCouponCodeInput, teamEntries, divisionFees, + addonQuantities, + addonLineItems, answers, agreedWaivers, allRequiredWaiversAgreed, @@ -598,6 +699,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/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/api/webhooks/stripe.ts b/apps/wodsmith-start/src/routes/api/webhooks/stripe.ts index 893b56d9c..bd91143a1 100644 --- a/apps/wodsmith-start/src/routes/api/webhooks/stripe.ts +++ b/apps/wodsmith-start/src/routes/api/webhooks/stripe.ts @@ -10,17 +10,17 @@ * - account.application.deauthorized: Clears team Stripe connection (inline) */ +import { env } from "cloudflare:workers" import { createFileRoute } from "@tanstack/react-router" import { json } from "@tanstack/react-start" -import { env } from "cloudflare:workers" import { and, eq } from "drizzle-orm" import type Stripe from "stripe" import { getDb } from "@/db" import { COMMERCE_PURCHASE_STATUS, - FINANCIAL_EVENT_TYPE, commercePurchaseTable, competitionsTable, + FINANCIAL_EVENT_TYPE, financialEventTable, teamTable, } from "@/db/schema" @@ -32,8 +32,8 @@ import { } from "@/lib/logging/posthog-otel-logger" import { getStripe } from "@/lib/stripe" import { - recordDisputeEvent, - recordRefundCompleted, + recordDisputeEvent, + recordRefundCompleted, } from "@/server/commerce/financial-events" import { notifyPaymentExpired } from "@/server/notifications" import type { CheckoutCompletedParams } from "@/workflows/stripe-checkout-workflow" @@ -234,15 +234,13 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ if (!paymentIntentId) { logWarning({ - message: - "[Stripe Webhook] Dispute has no payment_intent", + message: "[Stripe Webhook] Dispute has no payment_intent", attributes: { disputeId: dispute.id }, }) return } - const result = - await findPurchaseByPaymentIntent(paymentIntentId) + const result = await findPurchaseByPaymentIntent(paymentIntentId) if (!result) { logWarning({ message: @@ -260,7 +258,10 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ const existing = await db.query.financialEventTable.findFirst({ where: and( eq(financialEventTable.stripeDisputeId, dispute.id), - eq(financialEventTable.eventType, FINANCIAL_EVENT_TYPE.DISPUTE_OPENED), + eq( + financialEventTable.eventType, + FINANCIAL_EVENT_TYPE.DISPUTE_OPENED, + ), ), columns: { id: true }, }) @@ -297,15 +298,15 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ if (!paymentIntentId) return - const result = - await findPurchaseByPaymentIntent(paymentIntentId) + const result = await findPurchaseByPaymentIntent(paymentIntentId) if (!result) return - const eventType = dispute.status === "won" - ? FINANCIAL_EVENT_TYPE.DISPUTE_WON - : dispute.status === "lost" - ? FINANCIAL_EVENT_TYPE.DISPUTE_LOST - : null + const eventType = + dispute.status === "won" + ? FINANCIAL_EVENT_TYPE.DISPUTE_WON + : dispute.status === "lost" + ? FINANCIAL_EVENT_TYPE.DISPUTE_LOST + : null if (!eventType) return // Idempotency: skip if we already recorded this dispute resolution @@ -370,36 +371,61 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ if (!paymentIntentId) return - const result = - await findPurchaseByPaymentIntent(paymentIntentId) - if (!result) return - const db = getDb() + const purchases = await db.query.commercePurchaseTable.findMany({ + where: eq( + commercePurchaseTable.stripePaymentIntentId, + paymentIntentId, + ), + }) + if (purchases.length === 0) return // Check each refund on the charge for (const refund of charge.refunds?.data ?? []) { if (refund.status !== "succeeded") continue // Skip if we already recorded this refund (idempotency) - const existing = - await db.query.financialEventTable.findFirst({ - where: and( - eq( - financialEventTable.stripeRefundId, - refund.id, - ), - eq( - financialEventTable.eventType, - FINANCIAL_EVENT_TYPE.REFUND_COMPLETED, - ), + const existing = await db.query.financialEventTable.findFirst({ + where: and( + eq(financialEventTable.stripeRefundId, refund.id), + eq( + financialEventTable.eventType, + FINANCIAL_EVENT_TYPE.REFUND_COMPLETED, ), - columns: { id: true }, - }) + ), + columns: { id: true }, + }) if (existing) continue + const metadataPurchaseId = refund.metadata?.purchaseId + const purchase = metadataPurchaseId + ? purchases.find( + (candidate) => candidate.id === metadataPurchaseId, + ) + : purchases.length === 1 + ? purchases[0] + : undefined + if (!purchase?.competitionId) { + logWarning({ + message: + "[Stripe Webhook] Cannot attribute dashboard refund across a multi-line checkout", + attributes: { + paymentIntentId, + refundId: refund.id, + purchaseCount: purchases.length, + }, + }) + continue + } + const competition = await db.query.competitionsTable.findFirst({ + where: eq(competitionsTable.id, purchase.competitionId), + columns: { organizingTeamId: true }, + }) + if (!competition) continue + await recordRefundCompleted({ - purchaseId: result.purchase.id, - teamId: result.teamId, + purchaseId: purchase.id, + teamId: competition.organizingTeamId, amountCents: refund.amount, stripePaymentIntentId: paymentIntentId, stripeRefundId: refund.id, @@ -410,7 +436,7 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ message: "[Stripe Webhook] Recorded REFUND_COMPLETED from charge.refunded", attributes: { - purchaseId: result.purchase.id, + purchaseId: purchase.id, refundId: refund.id, amount: refund.amount, }, @@ -490,124 +516,73 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ ? session.payment_intent : (session.payment_intent?.id ?? null) - // Dispatch a workflow for EACH purchase (each division independent) - // Collect errors so one failure doesn't abort remaining purchases - const workflowErrors: Array<{ - purchaseId: string - error: unknown - }> = [] - - for (const purchaseId of purchaseIds) { - // Look up division from the purchase record - const purchase = - await getDb().query.commercePurchaseTable.findFirst({ - where: eq(commercePurchaseTable.id, purchaseId), - }) + const workflowParams: CheckoutCompletedParams = { + stripeEventId: event.id, + session: { + id: session.id, + payment_intent: paymentIntent, + amount_total: session.amount_total, + customer_email: session.customer_email, + metadata: { + purchaseIds, + competitionId, + userId, + couponId: session.metadata?.couponId, + couponCode: session.metadata?.couponCode, + couponDiscountCents: session.metadata?.couponDiscountCents, + }, + }, + } - if (!purchase) { - logWarning({ - message: - "[Stripe Webhook] Purchase not found, falling back to session metadata", - attributes: { purchaseId, eventId: event.id }, + const workflow = + "STRIPE_CHECKOUT_WORKFLOW" in env + ? (env.STRIPE_CHECKOUT_WORKFLOW as + | Workflow + | undefined) + : undefined + + if (workflow && typeof workflow.create === "function") { + try { + await workflow.create({ + id: event.id, + params: workflowParams, }) - } - - const divisionId = - purchase?.divisionId ?? session.metadata?.divisionId ?? "" - - const workflowParams: CheckoutCompletedParams = { - stripeEventId: event.id, - session: { - id: session.id, - payment_intent: paymentIntent, - amount_total: purchase?.totalCents ?? session.amount_total, - customer_email: session.customer_email, - metadata: { - purchaseId, + logInfo({ + message: + "[Stripe Webhook] Dispatched checkout session to workflow", + attributes: { + eventId: event.id, + purchaseIds: purchaseIds.join(","), competitionId, - divisionId, - userId, - couponId: session.metadata?.couponId, - stripeCouponId: session.metadata?.stripeCouponId, - couponCode: session.metadata?.couponCode, - couponDiscountCents: - session.metadata?.couponDiscountCents, }, - }, - } - - // Use event.id + purchaseId as key for multi-division idempotency - const workflowId = - purchaseIds.length > 1 - ? `${event.id}-${purchaseId}` - : event.id - - const workflow = - "STRIPE_CHECKOUT_WORKFLOW" in env - ? (env.STRIPE_CHECKOUT_WORKFLOW as - | Workflow - | undefined) - : undefined - - if (workflow && typeof workflow.create === "function") { - try { - await workflow.create({ - id: workflowId, - params: workflowParams, - }) + }) + } catch (workflowErr) { + const isConflict = + workflowErr instanceof Error && + workflowErr.message.includes("already exists") + if (isConflict) { logInfo({ message: - "[Stripe Webhook] Dispatched checkout to workflow", - attributes: { - eventId: event.id, - workflowId, - purchaseId, - competitionId, - divisionId, - }, + "[Stripe Webhook] Workflow already exists for event (idempotent)", + attributes: { eventId: event.id }, }) - } catch (workflowErr) { - const isConflict = - workflowErr instanceof Error && - workflowErr.message.includes("already exists") - if (isConflict) { - logInfo({ - message: - "[Stripe Webhook] Workflow already exists for event (idempotent)", - attributes: { eventId: event.id, workflowId }, - }) - } else { - logError({ - message: "[Stripe Webhook] Failed to dispatch workflow", - error: workflowErr, - attributes: { - eventId: event.id, - workflowId, - purchaseId, - }, - }) - workflowErrors.push({ purchaseId, error: workflowErr }) - } + } else { + throw workflowErr } - } else { - // Local dev: process inline - logInfo({ - message: - "[Stripe Webhook] Workflow binding unavailable, processing inline", - attributes: { eventId: event.id, purchaseId }, - }) - const { processCheckoutInline } = await import( - "@/workflows/stripe-checkout-workflow" - ) - await processCheckoutInline(workflowParams) } - } - - // If any workflow dispatches failed, throw so Stripe retries - if (workflowErrors.length > 0) { - throw new Error( - `Failed to dispatch ${workflowErrors.length} of ${purchaseIds.length} workflows`, + } else { + logInfo({ + message: + "[Stripe Webhook] Workflow binding unavailable, processing session inline", + attributes: { + eventId: event.id, + purchaseIds: purchaseIds.join(","), + }, + }) + const { processCheckoutInline } = await import( + "@/workflows/stripe-checkout-workflow" ) + await processCheckoutInline(workflowParams) } break } @@ -625,21 +600,15 @@ export const Route = createFileRoute("/api/webhooks/stripe")({ // Financial event tracking (disputes, refunds) case "charge.dispute.created": - await handleDisputeCreated( - event.data.object as Stripe.Dispute, - ) + await handleDisputeCreated(event.data.object as Stripe.Dispute) break case "charge.dispute.closed": - await handleDisputeClosed( - event.data.object as Stripe.Dispute, - ) + await handleDisputeClosed(event.data.object as Stripe.Dispute) break case "charge.refunded": - await handleChargeRefunded( - event.data.object as Stripe.Charge, - ) + await handleChargeRefunded(event.data.object as Stripe.Charge) break case "account.application.authorized": 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..084be0841 --- /dev/null +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx @@ -0,0 +1,794 @@ +/** + * 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/schema" +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?.competition + if (!competition) throw new Error("Competition not found") + + 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 + } + // Number() (not parseInt) so decimal input like "2.5" is rejected + // instead of silently truncated. + const parseWholeNumber = (raw: string): number => { + const value = Number(raw) + return Number.isInteger(value) ? value : Number.NaN + } + const maxPerAthlete = draft.maxPerAthlete.trim() + ? parseWholeNumber(draft.maxPerAthlete) + : null + if ( + maxPerAthlete !== null && + (Number.isNaN(maxPerAthlete) || maxPerAthlete <= 0) + ) { + toast.error("Max per athlete must be a positive whole number (or blank)") + 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 : parseWholeNumber(v.stock), + })) + if ( + variants.some( + (v) => + v.stockQty !== null && (Number.isNaN(v.stockQty) || v.stockQty < 0), + ) + ) { + toast.error("Stock must be a whole number of 0 or more (or blank)") + return + } + + const shared = { + name: draft.name.trim(), + description: draft.description.trim() || undefined, + imageUrl: draft.imageUrl.trim() || undefined, + priceCents, + maxPerAthlete, + 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. +

+
+
+ + +
+
+
+ +