@vitrine-kit/vitrine 0.3.1 → 0.4.1

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/dist/index.js +313 -135
  2. package/kit/registry/README.md +9 -0
  3. package/kit/registry/_index.json +74 -0
  4. package/kit/registry/cart/docs/cart.md +13 -0
  5. package/kit/registry/cart/feature.json +51 -0
  6. package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
  7. package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
  8. package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
  9. package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
  10. package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
  11. package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
  12. package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
  13. package/kit/registry/cart/files/lib/cart/data.ts +12 -0
  14. package/kit/registry/cart/files/lib/cart/register.ts +10 -0
  15. package/kit/registry/catalog/docs/catalog.md +18 -0
  16. package/kit/registry/catalog/feature.json +43 -0
  17. package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
  18. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
  19. package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
  20. package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
  21. package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
  22. package/kit/registry/checkout/docs/checkout.md +17 -0
  23. package/kit/registry/checkout/feature.json +47 -0
  24. package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
  25. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +46 -0
  26. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
  27. package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
  28. package/kit/registry/checkout-paddle/docs/checkout-paddle.md +25 -0
  29. package/kit/registry/checkout-paddle/feature.json +62 -0
  30. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
  31. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +80 -0
  32. package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
  33. package/kit/registry/checkout-stripe/docs/checkout-stripe.md +19 -0
  34. package/kit/registry/checkout-stripe/feature.json +54 -0
  35. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
  36. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +80 -0
  37. package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
  38. package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +23 -0
  39. package/kit/registry/checkout-yookassa/feature.json +52 -0
  40. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
  41. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +92 -0
  42. package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
  43. package/kit/registry/product-page/docs/product-page.md +16 -0
  44. package/kit/registry/product-page/feature.json +39 -0
  45. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
  46. package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
  47. package/kit/registry/product-page/files/lib/product/data.ts +10 -0
  48. package/kit/registry/seo/docs/seo.md +14 -0
  49. package/kit/registry/seo/feature.json +35 -0
  50. package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
  51. package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
  52. package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
  53. package/kit/registry/stubs/route-glue.d.ts +45 -0
  54. package/kit/registry/tsconfig.json +21 -0
  55. package/kit/registry/tsconfig.routes.json +9 -0
  56. package/kit/templates/README.md +27 -0
  57. package/kit/templates/backend-payload/files/.dockerignore +8 -0
  58. package/kit/templates/backend-payload/files/Dockerfile +29 -0
  59. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
  60. package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
  61. package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
  62. package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
  63. package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
  64. package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
  65. package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
  66. package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
  67. package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
  68. package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
  69. package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
  70. package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
  71. package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
  72. package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
  73. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
  74. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
  75. package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
  76. package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
  77. package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
  78. package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
  79. package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
  80. package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
  81. package/kit/templates/backend-payload/files/next.config.mjs +10 -0
  82. package/kit/templates/backend-payload/files/payload.config.ts +32 -0
  83. package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
  84. package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
  85. package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
  86. package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
  87. package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
  88. package/kit/templates/backend-vendure/files/.dockerignore +8 -0
  89. package/kit/templates/backend-vendure/files/Dockerfile +24 -0
  90. package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
  91. package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
  92. package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
  93. package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
  94. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
  95. package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
  96. package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
  97. package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
  98. package/kit/templates/backend-vendure/files/src/index.ts +15 -0
  99. package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
  100. package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
  101. package/kit/templates/base/files/.claude/commands/design.md +14 -0
  102. package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
  103. package/kit/templates/base/files/.claude/commands/setup.md +15 -0
  104. package/kit/templates/base/files/.claude/commands/update.md +16 -0
  105. package/kit/templates/base/files/.gitattributes +12 -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 +9 -5
@@ -0,0 +1,26 @@
1
+ // Minimal Lexical richText value from plain text — for the description of
2
+ // demo products during seeding.
3
+ export function plainToRichText(text: string): unknown {
4
+ return {
5
+ root: {
6
+ type: 'root',
7
+ format: '',
8
+ indent: 0,
9
+ version: 1,
10
+ direction: 'ltr',
11
+ children: [
12
+ {
13
+ type: 'paragraph',
14
+ format: '',
15
+ indent: 0,
16
+ version: 1,
17
+ direction: 'ltr',
18
+ textFormat: 0,
19
+ children: [
20
+ { type: 'text', text, format: 0, style: '', mode: 'normal', detail: 0, version: 1 },
21
+ ],
22
+ },
23
+ ],
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,51 @@
1
+ // Demo seed (§18.2): runs from Payload's onInit. Idempotent (guarded by
2
+ // shouldRunDevTask: dev only + empty collection). Images are local
3
+ // placeholders from seed-assets/ (no network).
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import type { Payload } from 'payload';
7
+ import { demoCategories, demoProducts } from './demo.js';
8
+ import { shouldRunDevTask } from './guards.js';
9
+ import { plainToRichText } from './richtext.js';
10
+
11
+ const seedAssets = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../seed-assets');
12
+
13
+ export async function seedDemo(payload: Payload): Promise<void> {
14
+ const isProd = process.env.NODE_ENV === 'production';
15
+ const { totalDocs } = await payload.count({ collection: 'products' });
16
+ if (!shouldRunDevTask({ isProd, existingCount: totalDocs })) return;
17
+
18
+ const categoryId = new Map<string, string | number>();
19
+ for (const c of demoCategories) {
20
+ const doc = await payload.create({ collection: 'categories', data: { slug: c.slug, title: c.title } });
21
+ categoryId.set(c.slug, doc.id);
22
+ }
23
+
24
+ for (const p of demoProducts) {
25
+ const media = await payload.create({
26
+ collection: 'media',
27
+ data: { alt: p.title },
28
+ filePath: path.join(seedAssets, p.image),
29
+ });
30
+ const catId = categoryId.get(p.category);
31
+ const product = await payload.create({
32
+ collection: 'products',
33
+ data: {
34
+ slug: p.slug,
35
+ title: p.title,
36
+ description: plainToRichText(p.description),
37
+ categories: catId ? [catId] : [],
38
+ images: [media.id],
39
+ seo: { title: p.seo.title, description: p.seo.description, image: media.id },
40
+ },
41
+ });
42
+ for (const v of p.variants) {
43
+ await payload.create({
44
+ collection: 'variants',
45
+ data: { sku: v.sku, price: v.price, stock: v.stock, product: product.id },
46
+ });
47
+ }
48
+ }
49
+
50
+ payload.logger.info('[vitrine] demo seed: created 5 products');
51
+ }
@@ -0,0 +1,10 @@
1
+ // backend-payload: wrap the Next config in withPayload (mounts the Payload admin and
2
+ // API into Next). output: 'standalone' — for the Docker image (VPS).
3
+ import { withPayload } from '@payloadcms/next/withPayload';
4
+
5
+ /** @type {import('next').NextConfig} */
6
+ const nextConfig = {
7
+ output: 'standalone',
8
+ };
9
+
10
+ export default withPayload(nextConfig);
@@ -0,0 +1,32 @@
1
+ // The client's Payload 3 config. Collections are assembled by the blueprint (base +
2
+ // additive feature extensions, lib/blueprint.ts — generated by the CLI). The DB is
3
+ // chosen zero-config (db.ts → §18.1). onInit runs the demo seed and dev admin (dev only).
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { buildConfig } from 'payload';
7
+ import type { CollectionConfig } from 'payload';
8
+ import { lexicalEditor } from '@payloadcms/richtext-lexical';
9
+ import sharp from 'sharp';
10
+ import { collections } from './lib/blueprint.js';
11
+ import { resolveDbAdapter } from './lib/adapter/db.js';
12
+ import { seedDemo } from './lib/seed/run.js';
13
+ import { ensureDevAdmin } from './lib/seed/admin.js';
14
+
15
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ export default buildConfig({
18
+ admin: {
19
+ user: 'users',
20
+ importMap: { baseDir: path.resolve(dirname) },
21
+ },
22
+ collections: collections as unknown as CollectionConfig[],
23
+ editor: lexicalEditor(),
24
+ secret: process.env.PAYLOAD_SECRET ?? 'dev-secret-change-me',
25
+ db: await resolveDbAdapter(),
26
+ sharp,
27
+ typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
28
+ async onInit(payload) {
29
+ await seedDemo(payload);
30
+ await ensureDevAdmin(payload);
31
+ },
32
+ });
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Classic T-Shirt">
2
+ <rect width="600" height="600" fill="#e7e5e4"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#d6d3d1"/>
4
+ <text x="300" y="312" font-family="system-ui, sans-serif" font-size="40" fill="#57534e" text-anchor="middle">T-Shirt</text>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Zip Hoodie">
2
+ <rect width="600" height="600" fill="#e0e7ff"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#c7d2fe"/>
4
+ <text x="300" y="312" font-family="system-ui, sans-serif" font-size="48" fill="#4338ca" text-anchor="middle">Hoodie</text>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Logo Cap">
2
+ <rect width="600" height="600" fill="#dcfce7"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#bbf7d0"/>
4
+ <text x="300" y="312" font-family="system-ui, sans-serif" font-size="48" fill="#15803d" text-anchor="middle">Cap</text>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Tote Bag">
2
+ <rect width="600" height="600" fill="#fef3c7"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#fde68a"/>
4
+ <text x="300" y="312" font-family="system-ui, sans-serif" font-size="44" fill="#b45309" text-anchor="middle">Tote</text>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Socks, 3 Pairs">
2
+ <rect width="600" height="600" fill="#fae8ff"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#f5d0fe"/>
4
+ <text x="300" y="312" font-family="system-ui, sans-serif" font-size="48" fill="#a21caf" text-anchor="middle">Socks</text>
5
+ </svg>
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ .next
3
+ .git
4
+ .vitrine
5
+ .env
6
+ .env.local
7
+ static/assets
8
+ **/*.tsbuildinfo
@@ -0,0 +1,24 @@
1
+ # syntax=docker/dockerfile:1
2
+ # Vitrine client image on Vendure. One image, two commands (compose): the Vendure server
3
+ # (`node dist/index.js`) and the Next storefront (`next start`). Target — VPS.
4
+ FROM node:20-alpine AS base
5
+ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
6
+ WORKDIR /app
7
+
8
+ FROM base AS deps
9
+ COPY package.json pnpm-lock.yaml* ./
10
+ RUN pnpm install --frozen-lockfile || pnpm install
11
+
12
+ FROM base AS build
13
+ ENV NEXT_TELEMETRY_DISABLED=1
14
+ COPY --from=deps /app/node_modules ./node_modules
15
+ COPY . .
16
+ RUN pnpm build
17
+
18
+ FROM base AS runtime
19
+ ENV NODE_ENV=production
20
+ WORKDIR /app
21
+ COPY --from=build /app ./
22
+ EXPOSE 3000 3001
23
+ # The command is overridden in docker-compose (web / server).
24
+ CMD ["pnpm", "start"]
@@ -0,0 +1,50 @@
1
+ # Reference Vitrine deployment on Vendure: db (Postgres) + server (Vendure) + web (Next).
2
+ # Run: VENDURE_SUPERADMIN_PASSWORD=... 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
+ server:
20
+ build:
21
+ context: .
22
+ restart: unless-stopped
23
+ command: ['node', 'dist/index.js']
24
+ environment:
25
+ NODE_ENV: production
26
+ DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
27
+ VENDURE_SUPERADMIN_USERNAME: ${VENDURE_SUPERADMIN_USERNAME:-superadmin}
28
+ VENDURE_SUPERADMIN_PASSWORD: ${VENDURE_SUPERADMIN_PASSWORD:?set VENDURE_SUPERADMIN_PASSWORD}
29
+ ports:
30
+ - '3001:3001'
31
+ depends_on:
32
+ db:
33
+ condition: service_healthy
34
+
35
+ web:
36
+ build:
37
+ context: .
38
+ restart: unless-stopped
39
+ command: ['pnpm', 'start']
40
+ environment:
41
+ NODE_ENV: production
42
+ VENDURE_SHOP_API_URL: http://server:3001/shop-api
43
+ NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
44
+ ports:
45
+ - '3000:3000'
46
+ depends_on:
47
+ - server
48
+
49
+ volumes:
50
+ pgdata:
@@ -0,0 +1,27 @@
1
+ // Thin Vendure Shop API client (GraphQL over fetch, no dependencies). The active
2
+ // order's session uses a bearer token (vendure-auth-token). Next glue.
3
+ const SHOP_API = process.env.VENDURE_SHOP_API_URL ?? 'http://localhost:3001/shop-api';
4
+
5
+ export interface ShopResult<T> {
6
+ data: T;
7
+ token?: string;
8
+ }
9
+
10
+ export async function shopQuery<T>(
11
+ query: string,
12
+ variables: Record<string, unknown> = {},
13
+ token?: string,
14
+ ): Promise<ShopResult<T>> {
15
+ const res = await fetch(SHOP_API, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'content-type': 'application/json',
19
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
20
+ },
21
+ body: JSON.stringify({ query, variables }),
22
+ });
23
+ const nextToken = res.headers.get('vendure-auth-token') ?? token ?? undefined;
24
+ const json = (await res.json()) as { data: T; errors?: unknown[] };
25
+ if (json.errors) throw new Error(`[vitrine] Vendure GraphQL: ${JSON.stringify(json.errors)}`);
26
+ return { data: json.data, token: nextToken };
27
+ }
@@ -0,0 +1,20 @@
1
+ // Resolver for the active backend (Vendure). The same interface as backend-payload —
2
+ // so the storefront (app/(frontend)) and the catalog/cart features don't change.
3
+ import type { CatalogSource, CommerceBackend } from '@vitrine-kit/contracts';
4
+ import { VendureCatalogSource } from './vendure-catalog.js';
5
+ import { VendureCommerceBackend } from './vendure-commerce.js';
6
+
7
+ let catalog: CatalogSource | null = null;
8
+ let commerce: CommerceBackend | null = null;
9
+
10
+ export async function getCatalogSource(): Promise<CatalogSource> {
11
+ if (!catalog) catalog = new VendureCatalogSource();
12
+ return catalog;
13
+ }
14
+
15
+ export async function getCommerceBackend(): Promise<CommerceBackend> {
16
+ if (!commerce) {
17
+ commerce = new VendureCommerceBackend(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000');
18
+ }
19
+ return commerce;
20
+ }
@@ -0,0 +1,107 @@
1
+ // Pure mappers from Vendure Shop API → contract types. They depend only on
2
+ // @vitrine-kit/contracts and local structural types. This is the proof of
3
+ // portability: the same Product/Category/Cart/Order types the Payload adapter returns
4
+ // — meaning the catalog/cart features work on Vendure WITHOUT changes.
5
+ import type { Cart, Category, Money, Order, OrderStatus, Product, Variant } from '@vitrine-kit/contracts';
6
+ import type { VAsset, VCollection, VOrder, VProduct, VVariant } from './vendure-types.js';
7
+
8
+ const asId = (v: string | number): string => String(v);
9
+
10
+ export function mapVendureCollection(c: VCollection): Category {
11
+ return {
12
+ id: asId(c.id),
13
+ slug: c.slug,
14
+ title: c.name,
15
+ description: c.description ?? undefined,
16
+ parentId: c.parent ? asId(c.parent.id) : null,
17
+ };
18
+ }
19
+
20
+ function mapVariant(v: VVariant): Variant {
21
+ return {
22
+ id: asId(v.id),
23
+ sku: v.sku,
24
+ title: v.name ?? undefined,
25
+ price: v.priceWithTax as Money,
26
+ currency: v.currencyCode,
27
+ stock: null, // the Shop API returns stockLevel as a string — we don't track numeric stock here
28
+ };
29
+ }
30
+
31
+ export function mapVendureProduct(p: VProduct): Product {
32
+ const variants = p.variants.map(mapVariant);
33
+ const assets = [p.featuredAsset, ...(p.assets ?? [])].filter((a): a is VAsset => a != null);
34
+ const images = assets.map((a) => ({ url: a.preview, width: a.width ?? undefined, height: a.height ?? undefined }));
35
+ const prices = variants.map((v) => v.price);
36
+ const currency = variants[0]?.currency ?? 'USD';
37
+
38
+ return {
39
+ id: asId(p.id),
40
+ slug: p.slug,
41
+ title: p.name,
42
+ description: p.description ?? undefined,
43
+ categoryIds: (p.collections ?? []).map((c) => asId(c.id)),
44
+ images,
45
+ variants,
46
+ priceRange: prices.length > 0 ? { min: Math.min(...prices), max: Math.max(...prices), currency } : undefined,
47
+ };
48
+ }
49
+
50
+ /** Vendure order state → contract OrderStatus. */
51
+ export function mapOrderState(state: string): OrderStatus {
52
+ switch (state) {
53
+ case 'PaymentSettled':
54
+ case 'PaymentAuthorized':
55
+ return 'paid';
56
+ case 'Shipped':
57
+ case 'Delivered':
58
+ case 'Fulfilled':
59
+ return 'fulfilled';
60
+ case 'Cancelled':
61
+ return 'cancelled';
62
+ default:
63
+ return 'pending';
64
+ }
65
+ }
66
+
67
+ /** Active Vendure order → Cart (the same the cart feature gets on Payload). */
68
+ export function mapVendureOrderToCart(o: VOrder): Cart {
69
+ return {
70
+ id: o.code,
71
+ currency: o.currencyCode,
72
+ subtotal: o.subTotalWithTax,
73
+ total: o.totalWithTax,
74
+ lines: o.lines.map((l) => ({
75
+ id: asId(l.id),
76
+ variantId: asId(l.productVariant.id),
77
+ productId: l.productVariant.product ? asId(l.productVariant.product.id) : '',
78
+ title: l.productVariant.name,
79
+ quantity: l.quantity,
80
+ unitPrice: l.unitPriceWithTax,
81
+ lineTotal: l.linePriceWithTax,
82
+ image: l.featuredAsset?.preview,
83
+ })),
84
+ };
85
+ }
86
+
87
+ export function mapVendureOrder(o: VOrder): Order {
88
+ const cart = mapVendureOrderToCart(o);
89
+ return {
90
+ id: asId(o.id),
91
+ number: o.code,
92
+ status: mapOrderState(o.state),
93
+ lines: cart.lines.map((l) => ({
94
+ variantId: l.variantId,
95
+ productId: l.productId,
96
+ title: l.title,
97
+ quantity: l.quantity,
98
+ unitPrice: l.unitPrice,
99
+ lineTotal: l.lineTotal,
100
+ })),
101
+ currency: o.currencyCode,
102
+ subtotal: o.subTotalWithTax,
103
+ total: o.totalWithTax,
104
+ email: o.customer?.emailAddress ?? undefined,
105
+ createdAt: o.createdAt ?? new Date().toISOString(),
106
+ };
107
+ }
@@ -0,0 +1,62 @@
1
+ // CatalogSource contract implementation on top of the Vendure Shop API. Normalization
2
+ // lives in the pure mappers (map.ts). The same contract as the Payload adapter → the
3
+ // catalog features are portable without changes.
4
+ import type { CatalogSource, Category, Product, ProductQuery } from '@vitrine-kit/contracts';
5
+ import { shopQuery } from './graphql.js';
6
+ import { mapVendureCollection, mapVendureProduct } from './map.js';
7
+ import type { VCollection, VProduct } from './vendure-types.js';
8
+
9
+ const PRODUCT_FIELDS = `
10
+ id name slug description
11
+ featuredAsset { preview width height }
12
+ assets { preview }
13
+ collections { id slug name }
14
+ variants { id sku name priceWithTax currencyCode stockLevel }
15
+ `;
16
+
17
+ export class VendureCatalogSource implements CatalogSource {
18
+ async listProducts(query: ProductQuery = {}): Promise<Product[]> {
19
+ const take = query.perPage ?? 24;
20
+ const skip = ((query.page ?? 1) - 1) * take;
21
+
22
+ if (query.category) {
23
+ const { data } = await shopQuery<{ collection: { productVariants: { items: { product: VProduct }[] } } | null }>(
24
+ `query ($slug: String!) { collection(slug: $slug) { productVariants { items { product { ${PRODUCT_FIELDS} } } } } }`,
25
+ { slug: query.category },
26
+ );
27
+ const byId = new Map<string, VProduct>();
28
+ for (const it of data.collection?.productVariants.items ?? []) byId.set(String(it.product.id), it.product);
29
+ return [...byId.values()].map(mapVendureProduct);
30
+ }
31
+
32
+ const { data } = await shopQuery<{ products: { items: VProduct[] } }>(
33
+ `query ($take: Int, $skip: Int) { products(options: { take: $take, skip: $skip }) { items { ${PRODUCT_FIELDS} } } }`,
34
+ { take, skip },
35
+ );
36
+ return data.products.items.map(mapVendureProduct);
37
+ }
38
+
39
+ async getProduct(slug: string): Promise<Product | null> {
40
+ const { data } = await shopQuery<{ product: VProduct | null }>(
41
+ `query ($slug: String!) { product(slug: $slug) { ${PRODUCT_FIELDS} } }`,
42
+ { slug },
43
+ );
44
+ return data.product ? mapVendureProduct(data.product) : null;
45
+ }
46
+
47
+ async listCategories(): Promise<Category[]> {
48
+ const { data } = await shopQuery<{ collections: { items: VCollection[] } }>(
49
+ `{ collections { items { id slug name description parent { id } } } }`,
50
+ );
51
+ return data.collections.items.map(mapVendureCollection);
52
+ }
53
+
54
+ async search(term: string): Promise<Product[]> {
55
+ const { data } = await shopQuery<{ search: { items: { slug: string }[] } }>(
56
+ `query ($term: String!) { search(input: { term: $term, groupByProduct: true }) { items { slug } } }`,
57
+ { term },
58
+ );
59
+ const products = await Promise.all(data.search.items.map((i) => this.getProduct(i.slug)));
60
+ return products.filter((p): p is Product => p !== null);
61
+ }
62
+ }
@@ -0,0 +1,76 @@
1
+ // CommerceBackend contract implementation on top of Vendure (active order + session
2
+ // token). cartId == the Vendure session auth-token. Money totals come from Vendure;
3
+ // normalization lives in map.ts. Payment — Vendure's Stripe plugin (startCheckout moves
4
+ // the order to ArrangingPayment). Next glue, validated against a running Vendure.
5
+ import type { Cart, CommerceBackend, Order } from '@vitrine-kit/contracts';
6
+ import { shopQuery } from './graphql.js';
7
+ import { mapVendureOrder, mapVendureOrderToCart } from './map.js';
8
+ import type { VOrder } from './vendure-types.js';
9
+
10
+ const ORDER_FIELDS = `
11
+ id code state currencyCode subTotalWithTax totalWithTax createdAt
12
+ customer { emailAddress }
13
+ lines {
14
+ id quantity unitPriceWithTax linePriceWithTax
15
+ featuredAsset { preview }
16
+ productVariant { id sku name product { id slug } }
17
+ }
18
+ `;
19
+
20
+ const emptyCart = (id: string): Cart => ({ id, lines: [], currency: 'USD', subtotal: 0, total: 0 });
21
+
22
+ export class VendureCommerceBackend implements CommerceBackend {
23
+ constructor(private readonly baseUrl: string) {}
24
+
25
+ async createCart(): Promise<Cart> {
26
+ // Vendure issues a session token on any request; the active order is created on addItem.
27
+ const { token } = await shopQuery(`{ activeChannel { id } }`);
28
+ return emptyCart(token ?? '');
29
+ }
30
+
31
+ async getCart(cartId: string): Promise<Cart | null> {
32
+ const { data } = await shopQuery<{ activeOrder: VOrder | null }>(`{ activeOrder { ${ORDER_FIELDS} } }`, {}, cartId);
33
+ return data.activeOrder ? mapVendureOrderToCart(data.activeOrder) : emptyCart(cartId);
34
+ }
35
+
36
+ async addItem(cartId: string, variantId: string, qty: number): Promise<Cart> {
37
+ const { data } = await shopQuery<{ addItemToOrder: VOrder }>(
38
+ `mutation ($id: ID!, $q: Int!) { addItemToOrder(productVariantId: $id, quantity: $q) { ...on Order { ${ORDER_FIELDS} } } }`,
39
+ { id: variantId, q: qty },
40
+ cartId,
41
+ );
42
+ return mapVendureOrderToCart(data.addItemToOrder);
43
+ }
44
+
45
+ async updateItem(cartId: string, lineId: string, qty: number): Promise<Cart> {
46
+ const { data } = await shopQuery<{ adjustOrderLine: VOrder }>(
47
+ `mutation ($id: ID!, $q: Int!) { adjustOrderLine(orderLineId: $id, quantity: $q) { ...on Order { ${ORDER_FIELDS} } } }`,
48
+ { id: lineId, q: qty },
49
+ cartId,
50
+ );
51
+ return mapVendureOrderToCart(data.adjustOrderLine);
52
+ }
53
+
54
+ async removeItem(cartId: string, lineId: string): Promise<Cart> {
55
+ const { data } = await shopQuery<{ removeOrderLine: VOrder }>(
56
+ `mutation ($id: ID!) { removeOrderLine(orderLineId: $id) { ...on Order { ${ORDER_FIELDS} } } }`,
57
+ { id: lineId },
58
+ cartId,
59
+ );
60
+ return mapVendureOrderToCart(data.removeOrderLine);
61
+ }
62
+
63
+ async startCheckout(cartId: string): Promise<{ redirectUrl: string }> {
64
+ // Transition to payment; Vendure's Stripe plugin creates a PaymentIntent on the payment page.
65
+ await shopQuery(`mutation { transitionOrderToState(state: "ArrangingPayment") { ...on Order { id } } }`, {}, cartId);
66
+ return { redirectUrl: `${this.baseUrl}/checkout/payment` };
67
+ }
68
+
69
+ async getOrder(id: string): Promise<Order | null> {
70
+ const { data } = await shopQuery<{ orderByCode: VOrder | null }>(
71
+ `query ($code: String!) { orderByCode(code: $code) { ${ORDER_FIELDS} } }`,
72
+ { code: id },
73
+ );
74
+ return data.orderByCode ? mapVendureOrder(data.orderByCode) : null;
75
+ }
76
+ }
@@ -0,0 +1,61 @@
1
+ // Structural shapes of Vendure Shop API responses (GraphQL). Intentionally loose and
2
+ // do NOT import the generated Vendure types — so that map.ts stays pure
3
+ // (contracts only) and testable without a running Vendure.
4
+
5
+ export type VId = string | number;
6
+
7
+ export interface VAsset {
8
+ preview: string;
9
+ width?: number | null;
10
+ height?: number | null;
11
+ }
12
+
13
+ export interface VCollection {
14
+ id: VId;
15
+ slug: string;
16
+ name: string;
17
+ description?: string | null;
18
+ parent?: { id: VId } | null;
19
+ }
20
+
21
+ export interface VVariant {
22
+ id: VId;
23
+ sku: string;
24
+ name?: string | null;
25
+ /** Vendure stores the price as an integer in minor units (like Money). */
26
+ priceWithTax: number;
27
+ currencyCode: string;
28
+ stockLevel?: string | null; // 'IN_STOCK' | 'OUT_OF_STOCK' | ... (not a number in the Shop API)
29
+ }
30
+
31
+ export interface VProduct {
32
+ id: VId;
33
+ name: string;
34
+ slug: string;
35
+ description?: string | null;
36
+ featuredAsset?: VAsset | null;
37
+ assets?: VAsset[] | null;
38
+ variants: VVariant[];
39
+ collections?: VCollection[] | null;
40
+ }
41
+
42
+ export interface VOrderLine {
43
+ id: VId;
44
+ quantity: number;
45
+ unitPriceWithTax: number;
46
+ linePriceWithTax: number;
47
+ featuredAsset?: VAsset | null;
48
+ productVariant: { id: VId; sku: string; name: string; product?: { id: VId; slug?: string } | null };
49
+ }
50
+
51
+ export interface VOrder {
52
+ id: VId;
53
+ code: string;
54
+ state: string;
55
+ currencyCode: string;
56
+ subTotalWithTax: number;
57
+ totalWithTax: number;
58
+ createdAt?: string;
59
+ customer?: { emailAddress?: string | null } | null;
60
+ lines: VOrderLine[];
61
+ }
@@ -0,0 +1,22 @@
1
+ // Vendure demo catalog (§18 equivalent): dev ONLY and when the DB is empty
2
+ // (idempotent). Full population — Vendure populate(app, initialData,
3
+ // productsCsv) or admin import; here — a guard + an integration point. The superadmin
4
+ // is created by Vendure from configuration (vendure-config.ts, env).
5
+ import type { INestApplicationContext } from '@nestjs/common';
6
+ import { Logger, ProductService, RequestContextService } from '@vendure/core';
7
+
8
+ export async function populateDemo(app: INestApplicationContext): Promise<void> {
9
+ if (process.env.NODE_ENV === 'production') return; // never in prod
10
+
11
+ const ctx = await app.get(RequestContextService).create({ apiType: 'admin' });
12
+ const { totalItems } = await app.get(ProductService).findAll(ctx, { take: 1 });
13
+ if (totalItems > 0) return; // idempotent: a non-empty DB — leave it alone
14
+
15
+ Logger.info(
16
+ '[vitrine] Vendure: empty DB in dev — populate the demo catalog via ' +
17
+ 'populate(app, ./seed/initial-data, ./seed/products.csv) or admin import.',
18
+ 'Vitrine',
19
+ );
20
+ // This is where Vendure populate() wires up with local initial-data + products.csv
21
+ // (offline, assets in static/). Left as an integration point (requires a running Vendure).
22
+ }
@@ -0,0 +1,15 @@
1
+ // Vendure server bootstrap (separate process: `pnpm vendure`). The Next storefront
2
+ // (`pnpm dev`) talks to the Shop API via lib/adapter. In dev with an empty DB —
3
+ // demo populate (guarded inside populateDemo).
4
+ import { bootstrap } from '@vendure/core';
5
+ import { config } from '../vendure-config.js';
6
+ import { populateDemo } from '../lib/seed/populate.js';
7
+
8
+ bootstrap(config)
9
+ .then(async (app) => {
10
+ await populateDemo(app);
11
+ })
12
+ .catch((err: unknown) => {
13
+ console.error('[vitrine] Vendure bootstrap failed:', err);
14
+ process.exit(1);
15
+ });