Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/docs/docs/how-to/organizers/sell-merch.md
Original file line number Diff line number Diff line change
@@ -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)*
2 changes: 2 additions & 0 deletions apps/docs/docs/tutorials/athletes/first-competition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions apps/wodsmith-start/scripts/seed/seeders/02-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ export async function seed(client: Connection): Promise<void> {
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,
},
Comment on lines +268 to +279

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an @lat reference for the new registration-addons feature seed.

This new feature record introduces a new concept anchor but doesn’t include a nearby // @lat: [[section-id]] trace comment required for TS source updates.

As per coding guidelines, **/*.{ts,tsx,js,jsx,rs,go,c,h,py} must use // @lat: [[section-id]] code-reference comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/wodsmith-start/scripts/seed/seeders/02-billing.ts` around lines 200 -
211, The new feature record with id "feat_registration_addons" / key
"registration_addons" is missing the required source-trace comment; add a nearby
single-line comment like // `@lat`: [[registration_addons]] immediately above (or
adjacent to) the object literal so the TS source updater can anchor this seed
entry; ensure the comment uses the exact // `@lat`: [[section-id]] format and
place it next to the record for "Registration Add-ons" (id:
"feat_registration_addons").

Source: Coding guidelines

])

// Limits
Expand Down
2 changes: 2 additions & 0 deletions apps/wodsmith-start/src/components/competition-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
Menu,
ReceiptText,
Settings,
ShoppingBag,
Sparkles,
Tag,
Trophy,
Expand Down Expand Up @@ -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",
Expand Down
223 changes: 223 additions & 0 deletions apps/wodsmith-start/src/components/registration/addons-section.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
aria-label={`Remove one ${label}`}
disabled={disabled || value <= 0}
onClick={() => onChange(value - 1)}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<span
className={cn(
"w-7 text-center text-sm tabular-nums",
value > 0 && "font-semibold",
)}
>
{value}
</span>
<Button
type="button"
variant="outline"
size="icon"
className="h-7 w-7"
aria-label={`Add one ${label}`}
disabled={disabled || value >= max}
onClick={() => onChange(value + 1)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
)
}

export function AddOnsSection({
addons,
quantities,
onQuantityChange,
disabled,
}: {
addons: PublicAddon[]
quantities: Map<string, number>
onQuantityChange: (
productId: string,
variantId: string | null,
quantity: number,
) => void
disabled?: boolean
}) {
if (addons.length === 0) return null

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingBag className="h-4 w-4" />
Event merch
</CardTitle>
<CardDescription>
Optional add-ons from the organizer, paid with your registration. Pick
up at the venue.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{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 (
<div key={addon.id} className="rounded-md border p-4">
<div className="flex gap-3">
{addon.imageUrl ? (
<img
src={addon.imageUrl}
alt={addon.name}
className="h-16 w-16 shrink-0 rounded-md border object-cover"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p className="font-medium">{addon.name}</p>
<p className="shrink-0 text-sm font-semibold">
${(addon.priceCents / 100).toFixed(2)}
</p>
</div>
{addon.description ? (
<p className="mt-0.5 text-sm text-muted-foreground">
{addon.description}
</p>
) : null}
<p className="mt-0.5 text-xs text-muted-foreground">
{addon.availableUntil
? `Order by ${formatRegistrationDate(addon.availableUntil)}`
: "Only available with registration"}
{addon.maxPerAthlete !== null
? ` · Max ${addon.maxPerAthlete} per athlete`
: ""}
</p>
</div>
</div>

{addon.variants.length > 0 ? (
<div className="mt-3 space-y-2">
{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 (
<div
key={variant.id}
className="flex items-center justify-between gap-3"
>
<span className="text-sm">
{variant.label}
{variant.soldOut ? (
<span className="ml-2 text-xs font-medium text-muted-foreground">
Sold out
</span>
) : variant.remaining !== null &&
variant.remaining <= 5 ? (
<span className="ml-2 text-xs text-muted-foreground">
{variant.remaining} left
</span>
) : null}
</span>
<QuantityStepper
value={quantity}
max={variant.soldOut ? 0 : effectiveMax}
disabled={disabled}
label={`${addon.name} (${variant.label})`}
onChange={(next) =>
onQuantityChange(addon.id, variant.id, next)
}
/>
</div>
)
})}
</div>
) : (
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
Quantity
</span>
<QuantityStepper
value={quantityForProduct(null)}
max={getMaxSelectableQuantity(addon, null)}
disabled={disabled}
label={addon.name}
onChange={(next) => onQuantityChange(addon.id, null, next)}
/>
</div>
)}
</div>
)
})}
</CardContent>
</Card>
)
}
Loading
Loading