Modules

Products and Store

Edit page
Configure a storefront for digital products and services with checkout, delivery, and invoice integration.

The products layer, published as @nuxt-customer-portal/products, provides one provider-owned store with localized products, categories, prices, checkout, purchase history, and protected files. Products may be free or paid, digital or service-based, and published only after their required content, media, prices, and files are complete.

Store setup

Administrators configure the store under /admin/products/settings:

  • General settings control whether the store is open, sandbox or live mode, languages, currencies, tax behavior, and image requirements.
  • Styles control the storefront and Markdown presentation.
  • Stripe stores organization-specific credentials only when Stripe is not supplied by the deployment environment.
  • Storage configures local-compatible S3 or Bunny storage for product images and purchased files.

New stores start in sandbox mode. Sandbox checkout tests successful, failed, and expired payments without Stripe. Switch to live mode only after Stripe, webhook delivery, storage, email, tax, and invoice settings have been verified.

Catalog and purchases

Product administration supports localized content, stable category assignments, multi-currency prices, image-purpose crops, downloadable files, publish checks, and previews. The public store exposes published products and creates immutable order snapshots so later catalog edits do not rewrite purchases.

Clients see their purchases under /purchases. Purchase details open in a drawer with the order reference, price, date, invoice action, next steps, and downloadable files. Anonymous checkout creates or links a private client by verified email and can invite that customer into the portal.

Stripe and delivery

Paid checkout uses Stripe Checkout and the signed webhook at /api/store/webhooks/stripe. Configure the webhook for the payment and checkout events listed in Store settings. Processing is idempotent: repeated events reuse the order, payment, delivery, email, and integration records.

Uploaded-file delivery requires configured storage. External purchased links do not require file storage for delivery. Free products use the normal purchase-confirmation and delivery flow without Stripe or an invoice.

Invoice integration

Install the invoice-products layer, published as @nuxt-customer-portal/invoice-products, with Products and Invoices. A completed paid order creates one sales invoice from its immutable lines and payment. Refunds create linked credit notes without rewriting the original invoice. Complete the invoice sender, numbering, tax, and transactional email configuration before opening the live store.

Composition

portal.config.ts
layers: [
  '@nuxt-customer-portal/preset',
  '@nuxt-customer-portal/invoices',
  '@nuxt-customer-portal/products',
  '@nuxt-customer-portal/invoice-products'
]

Add Planning after Products when services need bookable appointments.

Scheduled offers

Store administrators can add, edit, and cancel multiple offers in a product's pricing section. Each offer has an exact start and end time, shown in the administrator's browser timezone and stored in UTC. The start is included; the end is excluded. Overlapping offers and ambiguous daylight-saving times are rejected. Set a reduced amount in every configured currency, or choose a free offer. Regular prices resume automatically after the end.

The store catalog list, detail, and storefront APIs resolve prices at request time without caching. Each prices[] item retains its effective id, currency, amount, and taxBehavior, and adds originalAmount (the regular price) and offer ({ id, startsAt, endsAt } while active, otherwise null). Without an offer, originalAmount equals amount. Amounts use currency minor units; a free offer returns zero. isFree reflects effective pricing. Currency filters apply to active offers.

Offer management uses the existing store-admin authorization: POST /api/products/admin/products/:id/offers, PUT /api/products/admin/products/:id/offers/:offerId, and DELETE /api/products/admin/products/:id/offers/:offerId. Create/update bodies contain startsAt and endsAt as ISO timestamps with offsets, isFree, and prices (currency, amount, taxBehavior; empty for free offers). Store tax settings determine the applied tax behavior. Admin product reads include offers separately from regular prices. Cancellation retains historical offers and order references. Ended offers are read-only.

Ended or cancelled offers can be archived to tidy the pricing section without deleting historical prices or purchases. Archived offers are hidden by default; enable Show archived offers to view them or use Restore offer to return them to the list. Restoring does not reactivate an ended or cancelled offer. Active and scheduled offers must end or be cancelled before archiving. The store-admin endpoint PUT /api/products/admin/products/:id/offers/:offerId/archive accepts { archived: true } or { archived: false }; admin offer records include the archived flag. Apply Products migration 0027_offer_archive.sql before deploying this functionality. Existing offers start unarchived.

Checkout rechecks the price and rejects stale price IDs with a refresh-required conflict. Service reservations do not lock offer prices; a changed quote requires a new reservation. Created orders and checkout retries retain their agreed price. Regular-price or store-currency changes that invalidate active or future offers are rejected until those offers are updated or cancelled. Existing appointment-change fees are unaffected.

Purchased-file delivery

Apply the products migrations when upgrading. Each order has a permanent, unguessable purchase token. Paid orders (including free purchases) expose only the ready files in their purchase snapshot. Links require no portal account and recheck payment, refunds, and disputes on every request. Anyone holding a link can use it; keep purchase tokens out of analytics, logs, and public caches.

Confirmation emails include a localized list of file links using the {{downloads}} placeholder. If you customized the purchase email body, add this placeholder to your override. Existing purchases also receive tokens during migration; already-sent emails are not resent.

The downloads placeholder contains purchased file links and external links. Add a conditional section to customize the introduction, and leave it out entirely when no files are available:

{{#if downloads}}
You can download your audio files here:

{{downloads}}
{{/if}}

In Admin → Email → Email texts, use Source view to edit the tags. The preview/test switch lets you check the same email without downloads. An optional {{else}} before {{/if}} supplies alternative text. Conditions work with any placeholder declared for that email; empty or whitespace-only values select the alternative. Conditions are evaluated before Markdown formatting, and nested sections are supported. Invalid, unclosed, or unknown conditions are rejected when saving or previewing. The global HTML email template supports the same syntax with its own placeholders.

Successful checkout URLs include a purchase token on the portal payment-result page. That page displays the files and passes the token to the configured returnUrl when the buyer follows its return action. To use a calling website's thank-you page, supply that page as checkout returnUrl (or configure it as the store return URL). No calling website is modified by installing the portal update.

The calling thank-you page can fetch the file list without portal login or a catalog API key:

const token = new URL(location.href).searchParams.get('purchase')
if (token) {
  const response = await fetch(`https://portal.example.com/api/store/purchases/${encodeURIComponent(token)}`)
  if (response.ok) {
    const { status, files, links } = await response.json()
    // Render files and external links using each entry’s name and url.
    // If status is pending, retry after a few seconds while payment is confirmed.
  }
}

The response contains { status, variant, files: [{ id, name, url }], links: [{ id, name, url }], appointments: [{ id, orderLineId, title, start, end, timezone, status }] }, without billing or customer information. When planning is installed, accessible scheduled orders include their current appointment dates and time ranges in the customer’s booked timezone. The portal thank-you and order details pages display these appointments, including cancellation status; orders without appointments return an empty list. Browser access is allowed from the origin of the order's original returnUrl; server-side retrieval also works. Use Referrer-Policy: no-referrer on the calling thank-you page and exclude its purchase query parameter from analytics. Never treat the token or a success URL as proof of payment: use the returned delivery lists, which remain empty until purchase access is granted.

Each file URL is a stable /api/store/purchases/:token/files/:assetId permalink. The portal resolves it to current storage delivery, so temporary S3 URLs can expire without breaking the permalink. Successful GET requests that initiate an attachment response or storage redirect create a products.download row with the order, order line, asset, source (public or portal), and timestamp. Previews and HEAD requests do not count. These records indicate delivery requests, not verified completion of the browser's file transfer. Storage failures before delivery do not count.

Pay later by invoice

General store settings offer independent Online payment and Pay later by invoice options. Existing stores retain online payment. Enable one or both; an invoice-only store does not need Stripe credentials or webhooks. Invoice integration and sender details are required for deferred payment. Free purchases continue without a payment choice or invoice.

Pay-later checkout requires tax-inclusive prices. The displayed amount is the agreed final total. Acceptance immediately releases files and confirms booked appointments, while the purchase remains unpaid. Each live order creates one linked draft invoice, without issuing it, sending it, or recording a payment. Purchase and appointment confirmations explain that the invoice follows separately.

Open the draft in invoice administration and confirm each line's tax rate using Confirm tax breakdown. This splits the agreed amount into net and tax without increasing the total; an explicit zero rate is allowed. Issuing is blocked until review is complete and the invoice total matches the accepted purchase. Then use the existing invoice sending, payment registration, and reminder actions. Purchase and order details reflect unpaid, partially paid, and paid amounts independently of access. Sandbox retains its existing policy of suppressing invoice/email effects unless development sandbox effects are enabled.

Unlisted products

Choose Unlisted in a product's basic details to keep it out of catalog lists, search, and the Store catalog API. New unlisted products receive a cryptographically random slug. Changing a listed product to unlisted replaces its previous slug without a redirect. Further edits preserve the generated slug. Publication rules still apply: draft and archived products cannot be purchased publicly.

Anyone with the URL can view and purchase an unlisted product. There are no separate tokens, access query parameters, recipient restrictions, or redemption limits. Individual product endpoints return visibility (listed or unlisted); listed products without stored visibility behave as listed. Keep unlisted URLs out of sitemaps and navigation, mark their detail pages noindex, and fetch each product directly by slug instead of searching a catalog response. Do not include unlisted details in shared catalog caches.

The catalog response also includes paymentMethods (online and/or invoice). Checkout accepts an optional paymentMethod; when omitted it selects online if enabled, otherwise invoice. Disabled methods are rejected. Free orders ignore payment selection. Deferred orders use status accepted, separately expose invoice payment progress in authenticated purchase/admin reads, and grant the same purchase access as paid orders.

Use regular prices and scheduled offers on unlisted products for free or reduced specials. When an offer ends, regular prices resume and the same URL remains valid. Visibility and slug changes do not affect existing purchases, invoice references, or file access.

Upgrade with products migration 0020_deferred_payments.sql and invoices migration 0004_deferred_invoice_review.sql before deploying the new checkout. Deploy portal support before updating an external storefront to fetch individual unlisted products.

In Purchased files and links, choose Add link to share a Google Doc, YouTube video, or another web resource. Give it a name in each enabled store language and one HTTP/HTTPS URL without embedded credentials. Edit, reorder, or remove links from this section. Both service and digital products support links; digital products can be published with links instead of uploaded files.

Links are delivered in the purchase email through {{downloads}}, on the order confirmation, and in the customer portal. Custom email introductions should refer to files and links. Product links are captured at checkout: changing or removing them does not alter earlier purchases, and existing purchases without links remain unchanged. This feature needs no database migration.

The portal reveals destinations only when existing purchase-access rules allow it, including accepted invoice orders. Public catalog and checkout responses do not expose these URLs. Once shared, access to the destination is controlled by the external service; portal refunds or disputes cannot revoke an already revealed URL. Configure document or video sharing permissions in that service.

Account-free order page

In Store → Orders, use Copy client link to share an order's private /store/orders/:purchaseToken page. Clients can download their assets, play uploaded audio and video, and open or preview purchased links without a portal account. The checkout confirmation's View order details action opens this page. New purchase email defaults include {{orderUrl}}; add that placeholder to customized templates to include the link. The existing {{url}} portal placeholder is unchanged. Appointment confirmation instructions include the order link as well. Already-sent emails are not resent.

The page uses the original order snapshot and checks current purchase access before showing instructions or resources. Paid/free purchases and accepted invoice orders follow the existing rules; pending, failed, fully refunded or disputed orders do not expose assets or destinations. Partial refunds hide the affected line's resources. The page displays order information without buyer email, billing address or payment credentials. Product edits or archiving do not rewrite purchased content, and closing the store does not invalidate existing links. Apply the Products migrations when upgrading; editable order-page wording is stored by 0022_page_texts.sql.

Keep these links private: anyone holding one can access the available content for that order. The page and its details API use private/no-store responses, no-index directives and a no-referrer policy. The account-free details endpoint is GET /api/store/purchases/:token/details, separate from the unchanged purchase-delivery API.

Uploaded media has a download fallback if the browser cannot play its format. A file URL with ?preview=1 serves inline media after the same access checks and is not counted as a download. Bunny storage forwards supported single-byte-range requests for seeking; S3 continues using signed delivery URLs. Preview support does not add transcoding or adaptive streaming.

Existing purchased links automatically support previews for YouTube and Vimeo, including Vimeo unlisted hashes. Choose Preview video to load the embedded player. Frames use an origin-only referrer so providers can identify the portal without receiving its private order path. Direct MP3/M4A/WAV/OGG audio and MP4/WebM video URLs use native players. Other links open externally, and Open link remains available if embedding is blocked by the provider. Sharing and playback permissions are still configured at the destination service.

Store texts

Open Store → Store texts to edit customer-facing page wording. The navigation follows Email texts: Order details under Order pages, plus Free order and Paid order under Checkout confirmations, with English and Dutch language buttons. The former Thank-you texts URL redirects here. Existing checkout overrides and per-product inheritance are preserved.

For Order details, customize the heading, formatted introduction, information labels, file/media actions, status labels, access explanations, loading/error messages and footer. Expand the field groups to edit individual labels and use the live preview to inspect paid, invoiced, pending, revoked, failed, expired and error states. The preview uses example content, with disabled links and media; it never reads customer orders or creates purchases. Language and section switches preserve drafts until you leave the editor. Save and restore defaults apply only to the selected language. Empty fields use the built-in default wording.

These store-wide page texts take effect immediately on existing and future order links. They do not rewrite purchased product content: product titles, post-purchase instructions, file names and link labels remain as captured at checkout. Admin read/save/reset/render endpoints require the store's normal product-management permission. Anonymous clients receive only the resolved wording, not administrative override records or store identifiers. Apply the new Products migration 0022_page_texts.sql before deploying the editor.

Store texts also support product-specific purchase introductions. All page headings, labels, actions, statuses and errors remain shared per language. Empty product introductions inherit the store introduction; reset removes only the selected product/language introduction. Orders containing a single distinct product use that product’s introduction, while mixed-product orders use the store introduction. Selecting a product automatically loads its introduction and previews its current title, price, instructions, files and links. Product-specific editing always previews that same product. Store-default editing has a separate preview product selector. Preview state sits with the preview controls; previews use no customer orders and disable media/download actions. Apply products migration 0023_product_page_texts.sql before deploying this update.

Media library

Open Store → Media library to upload and organize images independently of products. Single and batch uploads follow Marpos Studio's review flow. Set a title, alternative text, free-form media categories, photo credit and visibility. The editor supports crop, rotate, straighten, brightness, undo/redo and reset to the retained original, with independently editable image variants. JPEG, PNG, WebP and GIF are supported up to 10 MB; GIF animation is preserved and cannot be adjusted.

The library reuses the store's current S3/Bunny storage settings. New product image selections reuse library images and variants; existing product images remain available. Apply Products migration 0024_media_library.sql before deployment. Edited versions are committed atomically; metadata-only edits do not rewrite image files. Stale edits return a conflict, and abandoned/superseded files are cleaned after 24 hours by the reconciliation task.

All media access requires authentication. API keys can receive products.media:read for visible images/metadata, or products.media:manage for hidden entries, originals and mutations. Media endpoints live under /api/products/media; file descriptor URLs require the same authentication and return private image bytes. Library URLs require authentication. Product image selection uses the library: choose images, reorder their cards and save the product. The first image is always the thumbnail; display variants are resolved automatically and configured only in the media library. Only visible images selected by a published product are available through the storefront product-image endpoint; originals and other variants remain protected. Deleting an image or variant used by a saved product is blocked. Removing a product placement preserves the library image. Apply 0026_product_media.sql for this integration.

Image variants

Store settings → Image variants defines named outputs and their dimensions. Thumbnail, Gallery and Detail start with the existing product sizes; additional presets can be added. Full-image saves generate these automatically, so you do not need to prepare a thumbnail separately. Select a variant to make a custom crop or adjustment, or reset it to automatic generation. Automatic variants follow full-image edits; custom variants remain independent. Replacing the source photo resets them.

Changes to presets apply when each existing library image is next saved. Until then its stored files remain available, and the editor shows that an update is pending. This also applies to resized or removed presets. Existing product uploads keep their original settings and assets. Apply migration 0025_media_variants.sql; existing library thumbnails are preserved as custom sources.