@vitrine-kit/vitrine 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +18 -18
  2. package/dist/index.js +304 -285
  3. package/kit/registry/README.md +9 -0
  4. package/kit/registry/_index.json +74 -0
  5. package/kit/registry/cart/docs/cart.md +13 -0
  6. package/kit/registry/cart/feature.json +51 -0
  7. package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
  8. package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
  9. package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
  10. package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
  11. package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
  12. package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
  13. package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
  14. package/kit/registry/cart/files/lib/cart/data.ts +12 -0
  15. package/kit/registry/cart/files/lib/cart/register.ts +10 -0
  16. package/kit/registry/catalog/docs/catalog.md +18 -0
  17. package/kit/registry/catalog/feature.json +43 -0
  18. package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
  19. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
  20. package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
  21. package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
  22. package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
  23. package/kit/registry/checkout/docs/checkout.md +17 -0
  24. package/kit/registry/checkout/feature.json +47 -0
  25. package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
  26. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +31 -0
  27. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
  28. package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
  29. package/kit/registry/checkout-paddle/docs/checkout-paddle.md +22 -0
  30. package/kit/registry/checkout-paddle/feature.json +62 -0
  31. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
  32. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +59 -0
  33. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
  34. package/kit/registry/checkout-stripe/docs/checkout-stripe.md +17 -0
  35. package/kit/registry/checkout-stripe/feature.json +54 -0
  36. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
  37. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +73 -0
  38. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
  39. package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +20 -0
  40. package/kit/registry/checkout-yookassa/feature.json +52 -0
  41. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
  42. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +82 -0
  43. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
  44. package/kit/registry/product-page/docs/product-page.md +16 -0
  45. package/kit/registry/product-page/feature.json +39 -0
  46. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
  47. package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
  48. package/kit/registry/product-page/files/lib/product/data.ts +10 -0
  49. package/kit/registry/seo/docs/seo.md +14 -0
  50. package/kit/registry/seo/feature.json +35 -0
  51. package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
  52. package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
  53. package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
  54. package/kit/registry/stubs/route-glue.d.ts +45 -0
  55. package/kit/registry/tsconfig.json +21 -0
  56. package/kit/registry/tsconfig.routes.json +9 -0
  57. package/kit/templates/README.md +27 -0
  58. package/kit/templates/backend-payload/files/.dockerignore +8 -0
  59. package/kit/templates/backend-payload/files/Dockerfile +29 -0
  60. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
  61. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
  62. package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
  63. package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
  64. package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
  65. package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
  66. package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
  67. package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
  68. package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
  69. package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
  70. package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
  71. package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
  72. package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
  73. package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
  74. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
  75. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
  76. package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
  77. package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
  78. package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
  79. package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
  80. package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
  81. package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
  82. package/kit/templates/backend-payload/files/next.config.mjs +10 -0
  83. package/kit/templates/backend-payload/files/payload.config.ts +32 -0
  84. package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
  85. package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
  86. package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
  87. package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
  88. package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
  89. package/kit/templates/backend-vendure/files/.dockerignore +8 -0
  90. package/kit/templates/backend-vendure/files/Dockerfile +24 -0
  91. package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
  92. package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
  93. package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
  94. package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
  95. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
  96. package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
  97. package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
  98. package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
  99. package/kit/templates/backend-vendure/files/src/index.ts +15 -0
  100. package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
  101. package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
  102. package/kit/templates/base/files/.claude/commands/design.md +14 -0
  103. package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
  104. package/kit/templates/base/files/.claude/commands/setup.md +15 -0
  105. package/kit/templates/base/files/.claude/commands/update.md +16 -0
  106. package/kit/templates/base/files/AGENTS.md +20 -0
  107. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +28 -0
  108. package/kit/templates/base/files/app/(frontend)/layout.tsx +32 -0
  109. package/kit/templates/base/files/app/(frontend)/page.tsx +27 -0
  110. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +43 -0
  111. package/kit/templates/base/files/app/globals.css +6 -0
  112. package/kit/templates/base/files/components/layout/SiteFooter.tsx +15 -0
  113. package/kit/templates/base/files/components/layout/SiteHeader.tsx +23 -0
  114. package/kit/templates/base/files/design/README.md +9 -0
  115. package/kit/templates/base/files/lib/site.ts +10 -0
  116. package/kit/templates/base/files/next.config.mjs +7 -0
  117. package/kit/templates/base/files/postcss.config.mjs +7 -0
  118. package/kit/templates/base/files/public/.gitkeep +0 -0
  119. package/kit/templates/base/files/tailwind.config.ts +13 -0
  120. package/kit/templates/base/files/tsconfig.json +25 -0
  121. package/kit/templates/tsconfig.json +18 -0
  122. package/package.json +7 -6
@@ -0,0 +1,16 @@
1
+ # Feature: product-page
2
+
3
+ The product detail page. Depends on `catalog` (registryDependencies).
4
+
5
+ ## Components
6
+ - `components/product/ProductView` — the detail card. **Hosts the slots** `product.gallery`, `product.below-title`, `product.below-price`, `product.purchase`, `product.below-description`, `product.tabs` — other features (reviews, cart, wishlist) mount here.
7
+ - `components/product/ProductGallery` — the image gallery.
8
+
9
+ ## Data
10
+ - `lib/product/data` — `loadProduct(source, slug)` over `CatalogSource.getProduct`.
11
+
12
+ ## Slots
13
+ The feature doesn't register slots, it **provides** them: so adding reviews/cart doesn't require editing `ProductView`.
14
+
15
+ ## Contracts
16
+ Only `@vitrine-kit/contracts` (types) and `@vitrine-kit/core/react` (`<Slot>`).
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "product-page",
4
+ "title": "Product page",
5
+ "kitVersion": "0.4.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "catalog",
9
+ "simple-store",
10
+ "full-store"
11
+ ],
12
+ "registryDependencies": [
13
+ "catalog"
14
+ ],
15
+ "corePackages": {
16
+ "@vitrine-kit/core": ">=0.1.0"
17
+ },
18
+ "npm": [],
19
+ "files": [
20
+ {
21
+ "from": "files/components/product/",
22
+ "to": "components/product/"
23
+ },
24
+ {
25
+ "from": "files/lib/product/",
26
+ "to": "lib/product/"
27
+ }
28
+ ],
29
+ "config": {
30
+ "set": {
31
+ "features.product-page": true
32
+ }
33
+ },
34
+ "slots": [],
35
+ "env": [],
36
+ "claudeDoc": "docs/product-page.md",
37
+ "conflicts": [],
38
+ "removable": false
39
+ }
@@ -0,0 +1,37 @@
1
+ // Product gallery — presentational, neutral.
2
+ import type { ProductImage } from '@vitrine-kit/contracts';
3
+
4
+ export interface ProductGalleryProps {
5
+ images: ProductImage[];
6
+ title: string;
7
+ }
8
+
9
+ export function ProductGallery({ images, title }: ProductGalleryProps) {
10
+ const cover = images[0];
11
+ if (!cover) {
12
+ return <div className="aspect-square w-full rounded-md bg-muted" aria-hidden="true" />;
13
+ }
14
+ const rest = images.slice(1);
15
+ return (
16
+ <div className="vt-product-gallery">
17
+ <img
18
+ src={cover.url}
19
+ alt={cover.alt ?? title}
20
+ className="w-full rounded-md object-cover"
21
+ />
22
+ {rest.length > 0 ? (
23
+ <ul role="list" className="mt-gutter flex gap-gutter">
24
+ {rest.map((img, i) => (
25
+ <li key={img.url}>
26
+ <img
27
+ src={img.url}
28
+ alt={img.alt ?? `${title} — image ${i + 2}`}
29
+ className="h-16 w-16 rounded-md object-cover"
30
+ />
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ ) : null}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,42 @@
1
+ // Product detail card. HOSTS the product.* slots — other features (reviews,
2
+ // cart, wishlist) mount into them without editing this component.
3
+ import type { Product } from '@vitrine-kit/contracts';
4
+ import { Slot } from '@vitrine-kit/core/react';
5
+ import { ProductGallery } from './ProductGallery.js';
6
+ import { formatPrice } from '../../lib/product/data.js';
7
+
8
+ export interface ProductViewProps {
9
+ product: Product;
10
+ }
11
+
12
+ export function ProductView({ product }: ProductViewProps) {
13
+ const price = product.priceRange?.min ?? product.variants[0]?.price;
14
+ const currency = product.priceRange?.currency ?? product.variants[0]?.currency ?? 'USD';
15
+
16
+ return (
17
+ <article className="vt-product-view grid gap-section md:grid-cols-2">
18
+ <div>
19
+ <Slot
20
+ name="product.gallery"
21
+ fallback={<ProductGallery images={product.images} title={product.title} />}
22
+ />
23
+ </div>
24
+ <div className="flex flex-col gap-gutter">
25
+ <Slot name="product.below-title" />
26
+ <h1 className="font-heading text-fg">{product.title}</h1>
27
+ {price != null ? (
28
+ <p className="text-price text-xl">{formatPrice(price, currency)}</p>
29
+ ) : null}
30
+ <Slot name="product.below-price" />
31
+ <div className="vt-product-purchase">
32
+ <Slot name="product.purchase" product={product} />
33
+ </div>
34
+ {product.description ? (
35
+ <div className="vt-product-description text-fg">{product.description}</div>
36
+ ) : null}
37
+ <Slot name="product.below-description" />
38
+ <Slot name="product.tabs" />
39
+ </div>
40
+ </article>
41
+ );
42
+ }
@@ -0,0 +1,10 @@
1
+ import type { CatalogSource, Product } from '@vitrine-kit/contracts';
2
+
3
+ export async function loadProduct(source: CatalogSource, slug: string): Promise<Product | null> {
4
+ return source.getProduct(slug);
5
+ }
6
+
7
+ /** Money — minor units (e.g. cents). */
8
+ export function formatPrice(amount: number, currency: string, locale = 'en-US'): string {
9
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
10
+ }
@@ -0,0 +1,14 @@
1
+ # Feature: seo
2
+
3
+ Page metadata and schema.org JSON-LD from domain types.
4
+
5
+ ## Modules
6
+ - `lib/seo/metadata` — `buildProductMetadata(product)` → `SiteMetadata` (structurally compatible with Next `Metadata`; in the client it's returned from `generateMetadata`).
7
+ - `lib/seo/jsonld` — `productJsonLd(product)` → a schema.org `Product`/`Offer` object.
8
+ - `components/seo/JsonLd` — `<JsonLd data={…} />` embeds a `<script type="application/ld+json">`.
9
+
10
+ ## Contracts
11
+ Only `@vitrine-kit/contracts` (the `Product` type). Registers no slots.
12
+
13
+ ## Note
14
+ The price in JSON-LD is converted from minor units (`price/100`).
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "seo",
4
+ "title": "SEO — metadata and JSON-LD",
5
+ "kitVersion": "0.4.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "catalog",
9
+ "simple-store",
10
+ "full-store"
11
+ ],
12
+ "registryDependencies": [],
13
+ "corePackages": {},
14
+ "npm": [],
15
+ "files": [
16
+ {
17
+ "from": "files/components/seo/",
18
+ "to": "components/seo/"
19
+ },
20
+ {
21
+ "from": "files/lib/seo/",
22
+ "to": "lib/seo/"
23
+ }
24
+ ],
25
+ "config": {
26
+ "set": {
27
+ "features.seo": true
28
+ }
29
+ },
30
+ "slots": [],
31
+ "env": [],
32
+ "claudeDoc": "docs/seo.md",
33
+ "conflicts": [],
34
+ "removable": false
35
+ }
@@ -0,0 +1,15 @@
1
+ // Embeds JSON-LD into the page. Usage:
2
+ // <JsonLd data={productJsonLd(product, { baseUrl })} />
3
+ export interface JsonLdProps {
4
+ data: Record<string, unknown>;
5
+ }
6
+
7
+ export function JsonLd({ data }: JsonLdProps) {
8
+ return (
9
+ <script
10
+ type="application/ld+json"
11
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD is safe — serialized data, not user HTML.
12
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
13
+ />
14
+ );
15
+ }
@@ -0,0 +1,29 @@
1
+ // schema.org JSON-LD from domain types.
2
+ import type { Product } from '@vitrine-kit/contracts';
3
+
4
+ export function productJsonLd(
5
+ product: Product,
6
+ opts: { baseUrl?: string } = {},
7
+ ): Record<string, unknown> {
8
+ const variant = product.variants[0];
9
+ const price = product.priceRange?.min ?? variant?.price;
10
+ const currency = product.priceRange?.currency ?? variant?.currency;
11
+
12
+ return {
13
+ '@context': 'https://schema.org',
14
+ '@type': 'Product',
15
+ name: product.title,
16
+ description: product.seo?.description ?? product.description,
17
+ image: product.images.map((image) => image.url),
18
+ sku: variant?.sku,
19
+ offers:
20
+ price != null
21
+ ? {
22
+ '@type': 'Offer',
23
+ price: (price / 100).toFixed(2),
24
+ priceCurrency: currency,
25
+ url: opts.baseUrl ? `${opts.baseUrl}/products/${product.slug}` : undefined,
26
+ }
27
+ : undefined,
28
+ };
29
+ }
@@ -0,0 +1,35 @@
1
+ // Builds page metadata from domain types. SiteMetadata is structurally
2
+ // compatible with Next Metadata — in the client, generateMetadata() returns this.
3
+ import type { Product } from '@vitrine-kit/contracts';
4
+
5
+ export interface SiteMetadata {
6
+ title: string;
7
+ description?: string;
8
+ canonical?: string;
9
+ openGraph?: {
10
+ title: string;
11
+ description?: string;
12
+ images?: string[];
13
+ };
14
+ }
15
+
16
+ export function buildProductMetadata(
17
+ product: Product,
18
+ opts: { baseUrl?: string } = {},
19
+ ): SiteMetadata {
20
+ const title = product.seo?.title ?? product.title;
21
+ const description = product.seo?.description ?? product.description;
22
+ const image = product.seo?.image ?? product.images[0]?.url;
23
+ const canonical = opts.baseUrl ? `${opts.baseUrl}/products/${product.slug}` : undefined;
24
+
25
+ return {
26
+ title,
27
+ description,
28
+ canonical,
29
+ openGraph: {
30
+ title,
31
+ description,
32
+ images: image ? [image] : undefined,
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,45 @@
1
+ // Ambient stubs so registry API route glue typechecks in the monorepo without Next/Payload installed.
2
+ // Real stack types are verified when the client is instantiated.
3
+
4
+ declare module 'next/server' {
5
+ export class NextResponse {
6
+ static json(body: unknown, init?: ResponseInit): NextResponse;
7
+ }
8
+ }
9
+
10
+ declare module 'next/headers' {
11
+ interface CookieStore {
12
+ get(name: string): { value: string } | undefined;
13
+ set(name: string, value: string, options?: Record<string, unknown>): void;
14
+ }
15
+ export function cookies(): Promise<CookieStore>;
16
+ }
17
+
18
+ declare module '@/lib/adapter' {
19
+ import type { CatalogSource, CommerceBackend } from '@vitrine-kit/contracts';
20
+ export function getCatalogSource(): Promise<CatalogSource>;
21
+ export function getCommerceBackend(): Promise<CommerceBackend>;
22
+ }
23
+
24
+ declare module '@/lib/checkout/fulfill' {
25
+ import type { NormalizedPaymentEvent, PaymentProviderName } from '@vitrine-kit/core';
26
+ export function fulfillOrderFromEvent(
27
+ event: NormalizedPaymentEvent,
28
+ providerName: PaymentProviderName,
29
+ ): Promise<void>;
30
+ }
31
+
32
+ declare module '@/lib/checkout-stripe/provider' {
33
+ import type { PaymentProvider } from '@vitrine-kit/core';
34
+ export const stripeProvider: PaymentProvider;
35
+ }
36
+
37
+ declare module '@/lib/checkout-paddle/provider' {
38
+ import type { PaymentProvider } from '@vitrine-kit/core';
39
+ export const paddleProvider: PaymentProvider;
40
+ }
41
+
42
+ declare module '@/lib/checkout-yookassa/provider' {
43
+ import type { PaymentProvider } from '@vitrine-kit/core';
44
+ export const yookassaProvider: PaymentProvider;
45
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "jsx": "react-jsx",
6
+ "types": ["react", "node"]
7
+ },
8
+ "//": "We typecheck the presentational components/ + the contract-based lib/ (they depend only on contracts/core). Next glue under files/app/** (routes, server actions, webhook) and payment glue (provider.ts pulls in the provider SDK, fulfill.ts — payload/@payload-config) are checked when the client is instantiated, not in the kit.",
9
+ "include": [
10
+ "*/files/components/**/*.ts",
11
+ "*/files/components/**/*.tsx",
12
+ "*/files/lib/**/*.ts",
13
+ "*/files/lib/**/*.tsx"
14
+ ],
15
+ "exclude": [
16
+ "**/node_modules",
17
+ "**/dist",
18
+ "*/files/lib/checkout-*/**",
19
+ "*/files/lib/checkout/fulfill.ts"
20
+ ]
21
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"]
6
+ },
7
+ "//": "Next API route glue (cart/checkout/webhooks). Uses registry/stubs/route-glue.d.ts instead of installing Next/Payload in the kit repo.",
8
+ "include": ["stubs/**/*.d.ts", "*/files/app/api/**/route.ts"]
9
+ }
@@ -0,0 +1,27 @@
1
+ # templates/ — client repository skeletons
2
+
3
+ `vitrine init` assembles the client repository from these (spec §6). `templates/<name>/files`
4
+ **mirrors the client root** (like `registry/<feature>/files`): the static skeleton is copied as-is,
5
+ then the CLI generates managed files on top of it (site.config.ts, vitrine.json, CLAUDE.md,
6
+ package.json, lib/slots.ts, lib/payments.ts, lib/blueprint.ts, theme/client.css).
7
+
8
+ - `base` — Next.js (App Router, route group `(frontend)`) + Tailwind (Vitrine preset),
9
+ storefront routes (home/catalog, product, category), header/footer hosting slots,
10
+ `.gitignore` (with `.vitrine/`), `/design` README.
11
+ - `backend-payload` — Payload config, the `(payload)` admin, a `PayloadCatalogSource`
12
+ adapter over the `CatalogSource` contract, zero-config dev
13
+ (SQLite fallback + demo seed + dev admin, §18), `Dockerfile` + `docker-compose.yml`
14
+ (app + Postgres, VPS hosting target).
15
+ - `backend-vendure` — A Vendure server (`vendure-config.ts`, Postgres/SQLite-dev db,
16
+ superadmin from env), `VendureCatalogSource`/`VendureCommerceBackend` adapters over the Shop
17
+ GraphQL API, a populate guard (§18 equivalent), `Dockerfile` + `docker-compose.yml` (db + server + web).
18
+ The storefront (`app/(frontend)`) and the catalog/cart features are the same as on Payload
19
+ (portability via contracts). Full payments use the Vendure Stripe plugin; the GPL-3.0 legal review
20
+ is a separate track.
21
+
22
+ ## What is checked in the monorepo
23
+
24
+ Template files that depend on Next/Payload are typechecked **when the client is instantiated**
25
+ (the stack is installed there). The pure critical logic (Payload→contract mappers, the §18.1 DB
26
+ selection table, demo-seed invariants, dev-procedure guards) depends only on the contracts and is
27
+ covered by `pnpm typecheck:templates` + tests in `sandbox/`.
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ .next
3
+ .git
4
+ .vitrine
5
+ .env
6
+ .env.local
7
+ **/*.tsbuildinfo
8
+ npm-debug.log
@@ -0,0 +1,29 @@
1
+ # syntax=docker/dockerfile:1
2
+ # Reference Vitrine client image (Next standalone + Payload). Target — VPS.
3
+ # @vitrine-kit/* packages are public on npm — no token needed to install.
4
+ FROM node:20-alpine AS base
5
+ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
6
+ WORKDIR /app
7
+
8
+ # --- dependencies ----------------------------------------------------------
9
+ FROM base AS deps
10
+ COPY package.json pnpm-lock.yaml* ./
11
+ RUN pnpm install --frozen-lockfile || pnpm install
12
+
13
+ # --- build ------------------------------------------------------------------
14
+ FROM base AS build
15
+ ENV NEXT_TELEMETRY_DISABLED=1
16
+ COPY --from=deps /app/node_modules ./node_modules
17
+ COPY . .
18
+ RUN pnpm build
19
+
20
+ # --- runtime ----------------------------------------------------------------
21
+ FROM base AS runtime
22
+ ENV NODE_ENV=production
23
+ ENV NEXT_TELEMETRY_DISABLED=1
24
+ WORKDIR /app
25
+ COPY --from=build /app/public ./public
26
+ COPY --from=build /app/.next/standalone ./
27
+ COPY --from=build /app/.next/static ./.next/static
28
+ EXPOSE 3000
29
+ CMD ["node", "server.js"]
@@ -0,0 +1,18 @@
1
+ /* Equivalent of the Payload-generated admin 404 page. */
2
+ import type { Metadata } from 'next';
3
+ import config from '@payload-config';
4
+ import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views';
5
+ import { importMap } from '../importMap.js';
6
+
7
+ type Args = {
8
+ params: Promise<{ segments: string[] }>;
9
+ searchParams: Promise<{ [key: string]: string | string[] }>;
10
+ };
11
+
12
+ export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
13
+ generatePageMetadata({ config, params, searchParams });
14
+
15
+ const NotFound = ({ params, searchParams }: Args) =>
16
+ NotFoundPage({ config, params, searchParams, importMap });
17
+
18
+ export default NotFound;
@@ -0,0 +1,18 @@
1
+ /* Equivalent of the Payload-generated admin page. */
2
+ import type { Metadata } from 'next';
3
+ import config from '@payload-config';
4
+ import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
5
+ import { importMap } from '../importMap.js';
6
+
7
+ type Args = {
8
+ params: Promise<{ segments: string[] }>;
9
+ searchParams: Promise<{ [key: string]: string | string[] }>;
10
+ };
11
+
12
+ export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
13
+ generatePageMetadata({ config, params, searchParams });
14
+
15
+ const Page = ({ params, searchParams }: Args) =>
16
+ RootPage({ config, params, searchParams, importMap });
17
+
18
+ export default Page;
@@ -0,0 +1,3 @@
1
+ // Import map for custom admin components. Regenerated by
2
+ // `payload generate:importmap` when custom view/field components are added.
3
+ export const importMap = {};
@@ -0,0 +1,18 @@
1
+ /* Payload REST API (equivalent of the generated file). */
2
+ import config from '@payload-config';
3
+ import '@payloadcms/next/css';
4
+ import {
5
+ REST_DELETE,
6
+ REST_GET,
7
+ REST_OPTIONS,
8
+ REST_PATCH,
9
+ REST_POST,
10
+ REST_PUT,
11
+ } from '@payloadcms/next/routes';
12
+
13
+ export const GET = REST_GET(config);
14
+ export const POST = REST_POST(config);
15
+ export const DELETE = REST_DELETE(config);
16
+ export const PATCH = REST_PATCH(config);
17
+ export const PUT = REST_PUT(config);
18
+ export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,6 @@
1
+ /* Payload GraphQL API (equivalent of the generated file). */
2
+ import config from '@payload-config';
3
+ import { GRAPHQL_POST, GRAPHQL_OPTIONS } from '@payloadcms/next/routes';
4
+
5
+ export const POST = GRAPHQL_POST(config);
6
+ export const OPTIONS = GRAPHQL_OPTIONS(config);
@@ -0,0 +1,5 @@
1
+ /* Payload GraphQL Playground (equivalent of the generated file). */
2
+ import config from '@payload-config';
3
+ import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes';
4
+
5
+ export const GET = GRAPHQL_PLAYGROUND_GET(config);
@@ -0,0 +1 @@
1
+ /* Custom Payload admin styles (optional). */
@@ -0,0 +1,23 @@
1
+ /* Payload admin layout (route group (payload)). Equivalent of the Payload-generated
2
+ file — renders its own <html> separately from the storefront ((frontend)). */
3
+ import type { ServerFunctionClient } from 'payload';
4
+ import config from '@payload-config';
5
+ import '@payloadcms/next/css';
6
+ import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
7
+ import type React from 'react';
8
+ import { importMap } from './admin/importMap.js';
9
+ import './custom.scss';
10
+
11
+ // biome-ignore lint/complexity/useArrowFunction: mirrors the Payload-generated file ('use server' boundary).
12
+ const serverFunction: ServerFunctionClient = async function (args) {
13
+ 'use server';
14
+ return handleServerFunctions({ ...args, config, importMap });
15
+ };
16
+
17
+ const Layout = ({ children }: { children: React.ReactNode }) => (
18
+ <RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
19
+ {children}
20
+ </RootLayout>
21
+ );
22
+
23
+ export default Layout;
@@ -0,0 +1,35 @@
1
+ # Reference Vitrine deployment on a VPS: app (Next+Payload) + Postgres.
2
+ # Run: PAYLOAD_SECRET=... docker compose up --build
3
+ services:
4
+ db:
5
+ image: postgres:16-alpine
6
+ restart: unless-stopped
7
+ environment:
8
+ POSTGRES_USER: vitrine
9
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vitrine}
10
+ POSTGRES_DB: vitrine
11
+ volumes:
12
+ - pgdata:/var/lib/postgresql/data
13
+ healthcheck:
14
+ test: ['CMD-SHELL', 'pg_isready -U vitrine']
15
+ interval: 5s
16
+ timeout: 5s
17
+ retries: 10
18
+
19
+ app:
20
+ build:
21
+ context: .
22
+ restart: unless-stopped
23
+ environment:
24
+ NODE_ENV: production
25
+ DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
26
+ PAYLOAD_SECRET: ${PAYLOAD_SECRET:?set PAYLOAD_SECRET}
27
+ NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
28
+ ports:
29
+ - '3000:3000'
30
+ depends_on:
31
+ db:
32
+ condition: service_healthy
33
+
34
+ volumes:
35
+ pgdata:
@@ -0,0 +1,43 @@
1
+ // Pure DB adapter selection logic (§18.1 of the spec). No dependencies or side
2
+ // effects — so it's covered by unit tests as the single source of truth for the
3
+ // decision table. The wrapper with real Payload adapters lives in db.ts.
4
+
5
+ export interface DbDecisionInput {
6
+ /** Value of DATABASE_URL (null/'' = not set). */
7
+ url: string | null;
8
+ /** Whether we managed to connect to Postgres (ping). */
9
+ canConnect: boolean;
10
+ isProd: boolean;
11
+ /** VITRINE_DB_STRICT=1 — disallows fallback even in dev. */
12
+ strict: boolean;
13
+ }
14
+
15
+ export type DbDecision =
16
+ | { kind: 'postgres' }
17
+ | { kind: 'sqlite'; warn?: string }
18
+ | { kind: 'error'; message: string };
19
+
20
+ /**
21
+ * Table §18.1:
22
+ * url + connect → postgres
23
+ * url, !connect, prod|strict → error (never fail silently in production)
24
+ * url, !connect, dev → sqlite + warn
25
+ * !url, prod → error
26
+ * !url, dev → sqlite
27
+ */
28
+ export function decideDbAdapter(input: DbDecisionInput): DbDecision {
29
+ const { url, canConnect, isProd, strict } = input;
30
+
31
+ if (url) {
32
+ if (canConnect) return { kind: 'postgres' };
33
+ if (isProd || strict) {
34
+ return { kind: 'error', message: '[vitrine] DATABASE_URL is set, but the DB is unreachable' };
35
+ }
36
+ return { kind: 'sqlite', warn: '[vitrine] Postgres unreachable → falling back to SQLite (dev)' };
37
+ }
38
+
39
+ if (isProd) {
40
+ return { kind: 'error', message: '[vitrine] DATABASE_URL is required in production' };
41
+ }
42
+ return { kind: 'sqlite' };
43
+ }
@@ -0,0 +1,29 @@
1
+ // Short TCP ping to Postgres with a timeout (~2s), then close. Uses node:net,
2
+ // no driver dependency — no need to distinguish unavailable/timeout/error:
3
+ // any failure in dev → fallback (see db-decision.ts).
4
+ import net from 'node:net';
5
+
6
+ export function canConnectPostgres(url: string, timeoutMs = 2000): Promise<boolean> {
7
+ let host = 'localhost';
8
+ let port = 5432;
9
+ try {
10
+ const u = new URL(url);
11
+ if (u.hostname) host = u.hostname;
12
+ if (u.port) port = Number(u.port);
13
+ } catch {
14
+ return Promise.resolve(false);
15
+ }
16
+
17
+ return new Promise((resolve) => {
18
+ const socket = new net.Socket();
19
+ const finish = (ok: boolean): void => {
20
+ socket.destroy();
21
+ resolve(ok);
22
+ };
23
+ socket.setTimeout(timeoutMs);
24
+ socket.once('connect', () => finish(true));
25
+ socket.once('timeout', () => finish(false));
26
+ socket.once('error', () => finish(false));
27
+ socket.connect(port, host);
28
+ });
29
+ }