-
Notifications
You must be signed in to change notification settings - Fork 2
feat(compete): registration add-ons (merch) gated by entitlement #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
cf55a8e
feat(compete): registration add-ons (merch) gated by entitlement
claude 3677c6d
fix(compete): harden registration add-ons from self-review findings
claude f99e20a
docs: organizer and athlete documentation for registration add-ons
claude 81bdf78
fix(compete): clamp Stripe application fee to the post-discount charge
claude 34b0efa
fix(compete): exclude addon purchases from series revenue rollups
claude 65f68ed
fix(compete): address cubic and CodeRabbit review findings on PR #511
claude 2e3c171
fix(compete): harden registration add-on checkout
zacjones93 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
223 changes: 223 additions & 0 deletions
223
apps/wodsmith-start/src/components/registration/addons-section.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add an
@latreference 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
Source: Coding guidelines