# Commerce: `@sonordev/commerce-kit/commerce`

Products, services, classes, events and checkout flows, all managed from the Sonor dashboard.

Requires site-kit 8 or newer. Coming from site-kit 7, where this was `@sonordev/site-kit/commerce`? The import path changed and little else: see [Moving from site-kit 7](https://sonor.dev/commerce-kit#moving-from-site-kit-7).

Checkout runs on the payment processor you connect to the project in Sonor, Square or Stripe. `EventCheckout`, the inline ticket checkout, needs Square.

## Components

| Component                                       | Purpose                                                                                                                                |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `OfferingCard`                                  | Card for any offering type: image, title, price and a call to action. Variants: `card`, `horizontal`, `minimal` and `featured`         |
| `OfferingList`                                  | Grid or list of offerings, filtered by type, category or status, with optional category filters                                        |
| `ProductPage`                                   | A whole product page for a `[slug]` route: breadcrumbs, `ProductDetail`, related products and a back link                              |
| `ProductDetail`                                 | The product itself: gallery, variants, quantity, size chart, features, specifications, trust badges and inline checkout                |
| `ProductGrid` / `ProductEmbed`                  | Product showcase widgets: a filterable, sortable grid, or one product for a homepage (a specific slug, or the latest)                  |
| `SizeChart`                                     | Clothing size chart display                                                                                                            |
| `EventTile` / `UpcomingEvents`                  | An event tile with date, title and a registration button, and a list of upcoming events built from it                                  |
| `EventCalendar` / `CalendarView` / `EventModal` | A month calendar with a built-in registration modal, the calendar alone, and the modal alone                                           |
| `EventEmbed` / `EventsWidget`                   | One event for a homepage (a specific slug, or the next one), and a full widget with list, grid and calendar views and booking built in |
| `EventsAgenda`                                  | A month-grouped event list. It has no hooks or browser APIs, so it renders in full on the server                                       |
| `EventCheckout`                                 | Inline checkout for an event with ticket tiers, paid by card in the page with Square                                                   |
| `CheckoutForm`                                  | Checkout or registration form for a product or event, with shipping rates when the project ships                                       |
| `RegistrationForm`                              | Registration for free events and classes                                                                                               |
| `CapacityBadge` / `EventJsonLd`                 | A seat-count badge, and schema.org Event markup from an offering you already fetched                                                   |

`useEventModal()` manages the state for opening `EventModal`: it returns `{ event, schedule, isOpen, openModal, closeModal }`.

## Usage

The `commerce` entry is a client entry. A page that imports these components stays a server component; the components load their data in the browser, using the credential `SiteKitLayout` published.

```tsx
// app/shop/page.tsx
import { OfferingList } from '@sonordev/commerce-kit/commerce'

export default function ShopPage() {
  return <OfferingList type="product" />
}
```

```tsx
// app/shop/[slug]/page.tsx, a single product page
import { ProductPage } from '@sonordev/commerce-kit/commerce'

export default async function Product({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return <ProductPage slug={slug} />
}
```

```tsx
// app/events/page.tsx, a calendar with booking built in
import { EventsWidget } from '@sonordev/commerce-kit/commerce'

export default function EventsPage() {
  return <EventsWidget title="Upcoming events" defaultView="calendar" />
}
```

## Event tickets

`EventCheckout` takes an event offering that has its schedules, such as one from `fetchOffering(slug)`:

```tsx
import { EventCheckout } from '@sonordev/commerce-kit/commerce'

<EventCheckout event={event} />
```

It shows one row for each ticket tier (a single ticket row when the event has no tiers, priced from the offering), collects the buyer's details and takes the card in the page with Square, in one charge for the total.

| Prop                    | What it does                                                                                                                                                                                                                                                                                                                  |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`                 | The event offering, with `variants` for tiers or a base price for a single ticket. Required                                                                                                                                                                                                                                   |
| `schedule`              | The date to book. Defaults to the event's first upcoming schedule                                                                                                                                                                                                                                                             |
| `collectPhone`          | Ask for a phone number. Default `true`                                                                                                                                                                                                                                                                                        |
| `maxPerVariant`         | The most tickets a buyer can pick in one tier. Default `10`                                                                                                                                                                                                                                                                   |
| `initialQuantities`     | Start with tiers already picked, as `{ [variantId]: quantity }`, so a tier card or a deep link can open checkout with that tier selected. Values are held between 0 and `maxPerVariant`, unknown ids are ignored, and it only sets the starting state: remount the checkout (for example with `key={variantId}`) to change it |
| `cardLabel`             | The card section's label. Default `Card Details`                                                                                                                                                                                                                                                                              |
| `submitText`            | The button text. Default `Buy` and the total                                                                                                                                                                                                                                                                                  |
| `onSuccess` / `onError` | Called with the `CheckoutResult` after a charge, or with an error message                                                                                                                                                                                                                                                     |
| `className`             | Class for the wrapper                                                                                                                                                                                                                                                                                                         |

## Render on the server

`@sonordev/commerce-kit/commerce/server` loads commerce data in server components, route handlers, `generateStaticParams` and `generateMetadata`. It's server only. Every fetcher takes a config as its first argument, and `{}` is a complete one: the helpers read `SONOR_API_KEY`, and `SONOR_API_URL` when you set it, like the rest of site-kit's server code.

```tsx
// app/shop/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { ProductPage } from '@sonordev/commerce-kit/commerce'
import { getOfferingBySlugResult, getProductPaths } from '@sonordev/commerce-kit/commerce/server'

export async function generateStaticParams() {
  return (await getProductPaths()).map((path) => path.params)
}

export default async function Product({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const { ok, data: product } = await getOfferingBySlugResult({}, slug)
  if (!product) {
    if (ok) notFound() // Sonor answered, and there's no such product
    throw new Error('Sonor didn’t answer, so this page can’t tell whether the product exists.')
  }
  // The product is already here, so it's in the page's HTML from the first byte.
  return <ProductPage product={product} />
}
```

| Helper                                                                                | What it returns                                                                                                                                                                                                   |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getOfferings(config, { type, category, limit, status })`                             | Offerings, active by default. `type` can be one type or an array, and `category` is a category id                                                                                                                 |
| `getOfferingBySlug(config, slug)`                                                     | One offering, or `null`                                                                                                                                                                                           |
| `getUpcomingEvents(config, { limit, category })`                                      | Upcoming events with their schedules, one item per upcoming date                                                                                                                                                  |
| `getNextEvent(config, category?)`                                                     | The next event, or `null`                                                                                                                                                                                         |
| `getProductPaths(config)`, `getEventPaths(config)`, `getOfferingPaths(config, type?)` | `{ params: { slug } }[]` for static generation. In the App Router, map each to its `params` as above                                                                                                              |
| `generateOfferingMetadata(offering, siteUrl)`                                         | `{ title, description, openGraph }` for an offering, using its SEO title and description when it has them, or `null`. `openGraph.url` is `siteUrl/<type>s/<slug>`, so set your own when your pages live elsewhere |
| `createServerConfig(apiUrl?, apiKey?)`                                                | A config with an address or key of your own. Leave both out and the helpers read your environment, the same as passing `{}`                                                                                       |

Each fetcher that returns a list or a single offering has a `*Result` twin (`getOfferingsResult`, `getOfferingBySlugResult`, `getUpcomingEventsResult`, `getNextEventResult`) that returns `{ ok, data }`. `ok: true` with empty data means Sonor answered and there's genuinely nothing; `ok: false` means the request failed. Branch on `ok` before a page says "nothing available" or returns a 404, because the plain helpers give the same empty answer for both.

Offerings come back exactly as the browser components load them, images, variants, size chart, notes and options included, so you can hand `ProductPage` or `ProductDetail` the product you fetched. A product marked sold out in Sonor keeps its page: it comes back with `status: 'sold_out'`, the components show it as unavailable with buying turned off, and checkout refuses it. If you draw a product with your own markup, check `status` the same way. Drafts and archived offerings have no page.

### Caching

Each response is cached for 60 seconds under the `commerce` cache tag, and Sonor expires that tag when you change an offering, an image, a schedule or stock. On a site that serves the [revalidation route](https://sonor.dev/site-kit/live-updates), an edit shows within seconds; on any other, within the minute. Change the window with `revalidate` in the config, in seconds, or pass `0` to fetch on every request (which makes the page render on every request too):

```ts
const products = await getOfferings({ revalidate: 300 }, { type: 'product' })
```

Every request has a time limit (10 seconds an attempt, 30 in all) and retries a busy or briefly unavailable API, so a slow day can't hang your build. It goes through site-kit's `serverApiFetch`, the same path as the rest of site-kit's server data.

### Helpers that run anywhere

`@sonordev/commerce-kit/commerce/utils` has the formatting helpers (`formatPrice`, `formatDate`, `formatDateTime`, `formatTime`, `formatDateRange`, `getRelativeTimeUntil`, `getSpotsRemaining`, `isEventSoldOut` and `getOfferingUrl`) and the calendar helpers (`icsForEvent`, `googleCalendarUrl` and `icsResponse`). It has no React, so a server component, `generateMetadata` or a route handler can call them, which they can't do through `@sonordev/commerce-kit/commerce`: that entry is a client module, and a function imported from one can't be called on the server.

```ts
// app/events/[slug]/calendar.ics/route.ts
import { getUpcomingEvents } from '@sonordev/commerce-kit/commerce/server'
import { icsResponse } from '@sonordev/commerce-kit/commerce/utils'

export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const event = (await getUpcomingEvents()).find((item) => item.slug === slug)
  const schedule = event?.next_schedule
  if (!event || !schedule) return new Response('Not found', { status: 404 })
  return icsResponse({ offering: event, schedule, url: `https://example.com/events/${slug}` })
}
```

## API functions

Browser calls to Sonor. They go through site-kit's transport, and the ones that create a sale, a registration or a payment are never retried automatically.

| Function                                            | What it does                                                                                                                                                                                                                                                    |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fetchOfferings(options)`                           | Offerings by `type`, `category` and `status` (default `active`), with `limit`, `offset`, `orderBy` and `order`                                                                                                                                                  |
| `fetchOffering(slug)`                               | One offering with its variants and upcoming schedules, or `null` when it can't be loaded                                                                                                                                                                        |
| `fetchLatestOffering(type?, category?)`             | The newest offering of a type                                                                                                                                                                                                                                   |
| `fetchProducts(options)`                            | Products. `fetchProductsPublic(options)` returns `{ products, total }` and also takes `search`                                                                                                                                                                  |
| `fetchProductBySlug(slug)`                          | One full product, or `null`                                                                                                                                                                                                                                     |
| `fetchCategories()`                                 | Categories as `{ id, name, slug }`                                                                                                                                                                                                                              |
| `fetchServices(options)`                            | Services                                                                                                                                                                                                                                                        |
| `fetchUpcomingEvents(options)`                      | Events with their schedules. `category` and `limit` narrow it, and `past: true` includes events that already happened                                                                                                                                           |
| `fetchNextEvent(category?)`                         | The next event, or `null`                                                                                                                                                                                                                                       |
| `fetchShippingRates(address, offerings)`            | Rates for a shipping address, when the project ships                                                                                                                                                                                                            |
| `shippingSelection(rate)`                           | Turns a chosen rate into the checkout fields, including its signed quote                                                                                                                                                                                        |
| `validateAddress(address)`                          | Checks a shipping address                                                                                                                                                                                                                                       |
| `validateDiscountCode(code, subtotal, offeringIds)` | Whether a code applies to an order, and the discount it gives                                                                                                                                                                                                   |
| `registerForEvent(eventId, scheduleId, customer)`   | Registers someone for a free event. Resolves `{ success, sale_id, confirmation_number }`, or `{ success: false, error }`                                                                                                                                        |
| `createCheckoutSession(options)`                    | Starts checkout for a product, service or event. Resolves `{ success, checkout_url, sale_id, confirmation_number }`, or `{ success: false, error }`. Pass `sourceId`, a card token from Square's Web Payments SDK, to charge in the page instead of redirecting |
| `createPaymentIntent(options)`                      | Creates a Stripe PaymentIntent, for paying by card in the page                                                                                                                                                                                                  |
| `fetchActiveProcessor()`                            | `'stripe'`, `'square'` or `null`                                                                                                                                                                                                                                |
| `fetchProcessorConfig()`                            | The public SDK settings for the active processor                                                                                                                                                                                                                |

Formatting helpers come with them: `formatPrice`, `formatDate`, `formatDateTime`, `formatTime`, `formatDateRange`, `getRelativeTimeUntil`, `getSpotsRemaining`, `isEventSoldOut` and `getOfferingUrl`. In server code, import them from `@sonordev/commerce-kit/commerce/utils` (see [Helpers that run anywhere](#helpers-that-run-anywhere)).

## Analytics

With site-kit's analytics on, the components report shopping events to Sonor themselves. There's nothing to wire up.

| Event            | Sent when                                                                                                                                                                           | Properties                                               |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `product_view`   | `ProductDetail` shows a product                                                                                                                                                     | `offering_id`, `name`, `price`, `type`, `category`       |
| `add_to_cart`    | Someone presses **Add to Cart** in `ProductDetail`, which shows that button when your page passes `onAddToCart`                                                                     | `offering_id`, `variant_id`, `quantity`, `price`, `name` |
| `begin_checkout` | Someone presses the main button in `ProductDetail` (**Buy Now**, or **Get It Free**) and checkout opens in the page. A button that links out to an external store doesn't report it | `offering_id`, `variant_id`, `quantity`, `price`, `name` |
| `purchase`       | A payment succeeds in `ProductDetail`                                                                                                                                               | the same, plus `sale_id`                                 |
| `checkout_start` | `CheckoutForm` is submitted                                                                                                                                                         | `offering_id`, `total`, `quantity`                       |

An event that happens before analytics has loaded waits for it, so the first product view on a page still counts. Checkout and event registration also send the visitor's analytics session, so Sonor can attribute a sale to the visit that led to it.

## Offering types

```ts
type OfferingType = 'product' | 'service' | 'class' | 'event' | 'subscription'

// Abbreviated: import the type for every field.
interface CommerceOffering {
  name: string; slug: string; type: OfferingType;
  description?: string; featured_image_url?: string;
  price_type: 'fixed' | 'variable' | 'quote' | 'free';
  price?: number; compare_at_price?: number; currency: string;
  track_inventory?: boolean; inventory_count?: number;
  is_clothing?: boolean; size_chart?: SizeChart;
  duration_minutes?: number; capacity?: number;
  location?: string; is_virtual?: boolean;
  schedules?: CommerceSchedule[]; variants?: CommerceVariant[];
}
```

This entry exports all the commerce types. Nine of them (`CommerceOffering`, `CommerceCategory`, `CommerceVariant`, `CommerceSchedule`, `OfferingType`, `Cart`, `CartItem`, `EventCheckoutProps` and `CheckoutLineItem`) are also exported from the root, `@sonordev/commerce-kit`, which has nothing that runs.

## Per-category styling (`data-category` / `data-offering-type`)

Event and offering surfaces expose the offering's category slug and type as data attributes, so sites can theme categories with plain CSS and no custom components. `data-category` is only present when the offering has a category; `data-offering-type` is always present.

Elements carrying the attributes:

- `CalendarView` / `EventCalendar`: the `.site-kit-calendar-event` chips (both `title` and `image` display modes) and the image-mode wrapper
- `EventTile` (both variants, which also covers `UpcomingEvents` and `EventEmbed`)
- `EventsWidget`: `.site-kit-event-card` in list and grid views
- `OfferingCard` (all variants, which also covers `OfferingList` / `ProductGrid`)

```css
/* e.g. two color schemes on one calendar: workshops vs community nights */
.site-kit-calendar-event[data-category="workshops"] {
  background: rgba(16, 185, 129, 0.15);
  color: #059669;
}
.site-kit-calendar-event[data-category="community"] {
  background: rgba(59, 130, 246, 0.15);
  color: #2563eb;
}
```

The components keep their `site-kit-` class prefix because sites style those names. Colors come from the same `--sk-*` brand tokens as the rest of site-kit.

## Size Charts (clothing products)

```ts
interface SizeChart {
  unit: 'inches' | 'cm'
  fit_note?: string                // e.g., "Runs small. Order one size up."
  measurements: string[]           // ['Chest', 'Length', 'Sleeve']
  rows: Array<{
    size: string                   // 'S', 'M', 'L', 'XL'
    values: number[]               // Primary unit values
    values_alt?: number[]          // Auto-converted alternate unit
  }>
}
```
