@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,27 @@
1
+ // DB adapter selection for Payload (§18.1). The decision lives in the pure
2
+ // decideDbAdapter; here we only wire up the real adapters and log the choice.
3
+ import { postgresAdapter } from '@payloadcms/db-postgres';
4
+ import { sqliteAdapter } from '@payloadcms/db-sqlite';
5
+ import { decideDbAdapter } from './db-decision.js';
6
+ import { canConnectPostgres } from './db-ping.js';
7
+
8
+ export async function resolveDbAdapter() {
9
+ const url = process.env.DATABASE_URL ?? null;
10
+ const decision = decideDbAdapter({
11
+ url,
12
+ isProd: process.env.NODE_ENV === 'production',
13
+ strict: process.env.VITRINE_DB_STRICT === '1',
14
+ canConnect: url ? await canConnectPostgres(url) : false,
15
+ });
16
+
17
+ if (decision.kind === 'error') throw new Error(decision.message);
18
+ if (decision.warn) console.warn(decision.warn);
19
+
20
+ if (decision.kind === 'postgres') {
21
+ console.info('[vitrine] DB: Postgres');
22
+ return postgresAdapter({ pool: { connectionString: url as string } });
23
+ }
24
+
25
+ console.info('[vitrine] DB: SQLite (.vitrine/dev.sqlite)');
26
+ return sqliteAdapter({ client: { url: 'file:./.vitrine/dev.sqlite' } });
27
+ }
@@ -0,0 +1,31 @@
1
+ // Resolver for the storefront's active backend. The catalog is served via the
2
+ // Payload local API; commerce (CommerceBackend) is enabled by the cart/checkout-stripe features.
3
+ import { getPayload } from 'payload';
4
+ import config from '@payload-config';
5
+ import type { CatalogSource, CommerceBackend } from '@vitrine-kit/contracts';
6
+ import { siteConfig } from '@/site.config';
7
+ import { registerPayments } from '@/lib/payments';
8
+ import { PayloadCatalogSource } from './payload-catalog.js';
9
+ import { PayloadCommerceBackend } from './payload-commerce.js';
10
+
11
+ // Register payment providers of installed features (lib/payments.ts is generated
12
+ // by the CLI). Needed before startCheckout, which resolves the active provider from the registry.
13
+ registerPayments();
14
+
15
+ let catalog: CatalogSource | null = null;
16
+ let commerce: CommerceBackend | null = null;
17
+
18
+ export async function getCatalogSource(): Promise<CatalogSource> {
19
+ if (catalog) return catalog;
20
+ const payload = await getPayload({ config });
21
+ catalog = new PayloadCatalogSource(payload, siteConfig.i18n.currency);
22
+ return catalog;
23
+ }
24
+
25
+ export async function getCommerceBackend(): Promise<CommerceBackend> {
26
+ if (commerce) return commerce;
27
+ const payload = await getPayload({ config });
28
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
29
+ commerce = new PayloadCommerceBackend(payload, siteConfig.i18n.currency, baseUrl, siteConfig);
30
+ return commerce;
31
+ }
@@ -0,0 +1,112 @@
1
+ // Pure mappers from Payload document → contract type. They depend only on
2
+ // @vitrine-kit/contracts and local structural types — so they typecheck and are
3
+ // covered by tests without Payload installed. This is the critical portability
4
+ // seam (the same contract on Payload and Vendure).
5
+ import type { Category, Money, Product, ProductImage, Variant } from '@vitrine-kit/contracts';
6
+ import type { CategoryDoc, Id, MediaDoc, ProductDoc, VariantDoc } from './payload-types.js';
7
+
8
+ const asId = (v: Id): string => String(v);
9
+
10
+ const relId = (v: Id | { id: Id }): string => (typeof v === 'object' ? asId(v.id) : asId(v));
11
+
12
+ /** Known base product fields — everything else goes into Product.extensions. */
13
+ const BASE_PRODUCT_KEYS = new Set([
14
+ 'id', 'slug', 'title', 'description', 'categories', 'images', 'seo',
15
+ 'createdAt', 'updatedAt', '_status',
16
+ ]);
17
+
18
+ export function mapCategory(doc: CategoryDoc): Category {
19
+ return {
20
+ id: asId(doc.id),
21
+ slug: doc.slug,
22
+ title: doc.title,
23
+ description: doc.description ?? undefined,
24
+ parentId: doc.parent == null ? null : relId(doc.parent),
25
+ };
26
+ }
27
+
28
+ export function mapVariant(doc: VariantDoc, currency: string): Variant {
29
+ return {
30
+ id: asId(doc.id),
31
+ sku: doc.sku,
32
+ price: doc.price as Money,
33
+ currency,
34
+ stock: doc.stock ?? null,
35
+ options: doc.options ?? undefined,
36
+ };
37
+ }
38
+
39
+ function mapImage(m: Id | MediaDoc): ProductImage | null {
40
+ // depth did not expand the relation (an id arrived) or there's no url — skip.
41
+ if (typeof m !== 'object' || !m.url) return null;
42
+ return {
43
+ url: m.url,
44
+ alt: m.alt ?? undefined,
45
+ width: m.width ?? undefined,
46
+ height: m.height ?? undefined,
47
+ };
48
+ }
49
+
50
+ /** Lexical richText (or string) → plain text. No HTML — for cards/SEO. */
51
+ export function richTextToPlain(value: unknown): string | undefined {
52
+ if (value == null) return undefined;
53
+ if (typeof value === 'string') return value || undefined;
54
+
55
+ const out: string[] = [];
56
+ const walk = (node: unknown): void => {
57
+ if (!node || typeof node !== 'object') return;
58
+ const n = node as { text?: unknown; children?: unknown; root?: unknown };
59
+ if (typeof n.text === 'string') out.push(n.text);
60
+ if (n.root) walk(n.root);
61
+ if (Array.isArray(n.children)) for (const child of n.children) walk(child);
62
+ };
63
+ walk(value);
64
+ const text = out.join(' ').replace(/\s+/g, ' ').trim();
65
+ return text || undefined;
66
+ }
67
+
68
+ function collectExtensions(doc: ProductDoc): Record<string, unknown> | undefined {
69
+ const ext: Record<string, unknown> = {};
70
+ for (const [key, val] of Object.entries(doc)) {
71
+ if (!BASE_PRODUCT_KEYS.has(key) && val !== undefined) ext[key] = val;
72
+ }
73
+ return Object.keys(ext).length > 0 ? ext : undefined;
74
+ }
75
+
76
+ export function mapProduct(doc: ProductDoc, variants: VariantDoc[], currency: string): Product {
77
+ const mappedVariants = variants.map((v) => mapVariant(v, currency));
78
+ const images = (doc.images ?? [])
79
+ .map(mapImage)
80
+ .filter((x): x is ProductImage => x !== null);
81
+ const categoryIds = (doc.categories ?? []).map(relId);
82
+
83
+ const prices = mappedVariants.map((v) => v.price);
84
+ const priceRange =
85
+ prices.length > 0
86
+ ? { min: Math.min(...prices), max: Math.max(...prices), currency }
87
+ : undefined;
88
+
89
+ const seoImage =
90
+ doc.seo?.image && typeof doc.seo.image === 'object' ? doc.seo.image.url ?? undefined : undefined;
91
+ const seo =
92
+ doc.seo?.title || doc.seo?.description || seoImage
93
+ ? {
94
+ title: doc.seo?.title ?? undefined,
95
+ description: doc.seo?.description ?? undefined,
96
+ image: seoImage,
97
+ }
98
+ : undefined;
99
+
100
+ return {
101
+ id: asId(doc.id),
102
+ slug: doc.slug,
103
+ title: doc.title,
104
+ description: richTextToPlain(doc.description),
105
+ categoryIds,
106
+ images,
107
+ variants: mappedVariants,
108
+ priceRange,
109
+ seo,
110
+ extensions: collectExtensions(doc),
111
+ };
112
+ }
@@ -0,0 +1,79 @@
1
+ // CatalogSource contract implementation on top of the Payload local API.
2
+ // All domain normalization lives in the pure mappers (map.ts). Here — only queries.
3
+ import type { Payload } from 'payload';
4
+ import type {
5
+ CatalogSource,
6
+ Category,
7
+ Product,
8
+ ProductQuery,
9
+ ProductSort,
10
+ } from '@vitrine-kit/contracts';
11
+ import { mapCategory, mapProduct } from './map.js';
12
+ import type { ProductDoc, VariantDoc } from './payload-types.js';
13
+
14
+ function sortExpr(sort?: ProductSort): string {
15
+ switch (sort) {
16
+ case 'price-asc':
17
+ return 'createdAt'; // price lives in the variants collection — price sorting is provided by the filters feature
18
+ case 'price-desc':
19
+ return '-createdAt';
20
+ default: // newest and unspecified sort
21
+ return '-createdAt';
22
+ }
23
+ }
24
+
25
+ export class PayloadCatalogSource implements CatalogSource {
26
+ constructor(
27
+ private readonly payload: Payload,
28
+ private readonly currency: string,
29
+ ) {}
30
+
31
+ private async withVariants(doc: ProductDoc): Promise<Product> {
32
+ const variants = await this.payload.find({
33
+ collection: 'variants',
34
+ where: { product: { equals: doc.id } },
35
+ depth: 0,
36
+ limit: 200,
37
+ });
38
+ return mapProduct(doc, variants.docs as unknown as VariantDoc[], this.currency);
39
+ }
40
+
41
+ async listProducts(query: ProductQuery = {}): Promise<Product[]> {
42
+ const where = query.category ? { 'categories.slug': { equals: query.category } } : {};
43
+ const res = await this.payload.find({
44
+ collection: 'products',
45
+ where,
46
+ depth: 1,
47
+ limit: query.perPage ?? 24,
48
+ page: query.page ?? 1,
49
+ sort: sortExpr(query.sort),
50
+ });
51
+ return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
52
+ }
53
+
54
+ async getProduct(slug: string): Promise<Product | null> {
55
+ const res = await this.payload.find({
56
+ collection: 'products',
57
+ where: { slug: { equals: slug } },
58
+ depth: 1,
59
+ limit: 1,
60
+ });
61
+ const doc = res.docs[0];
62
+ return doc ? this.withVariants(doc as unknown as ProductDoc) : null;
63
+ }
64
+
65
+ async listCategories(): Promise<Category[]> {
66
+ const res = await this.payload.find({ collection: 'categories', depth: 0, limit: 200 });
67
+ return res.docs.map((d) => mapCategory(d as unknown as never));
68
+ }
69
+
70
+ async search(term: string): Promise<Product[]> {
71
+ const res = await this.payload.find({
72
+ collection: 'products',
73
+ where: { title: { like: term } },
74
+ depth: 1,
75
+ limit: 24,
76
+ });
77
+ return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
78
+ }
79
+ }
@@ -0,0 +1,154 @@
1
+ // CommerceBackend contract implementation on top of Payload (carts/orders collections).
2
+ // Money arithmetic and the order come from @vitrine-kit/core (critical logic); here — only
3
+ // persistence. Creating the payment is delegated to the active payment provider
4
+ // (payments.resolve by site.config), so this module pulls in no payment SDK
5
+ // and loads at any layer (including catalog).
6
+ import type { Payload } from 'payload';
7
+ import type { Cart, CommerceBackend, Order, SiteConfig } from '@vitrine-kit/contracts';
8
+ import {
9
+ addCartLine,
10
+ emptyCart,
11
+ payments,
12
+ recalcCart,
13
+ removeCartLine,
14
+ setCartLineQty,
15
+ } from '@vitrine-kit/core';
16
+ import type { ProductDoc, VariantDoc } from './payload-types.js';
17
+
18
+ interface CartDoc {
19
+ id: string | number;
20
+ lines?: Cart['lines'];
21
+ currency?: string;
22
+ subtotal?: number;
23
+ discountTotal?: number;
24
+ total?: number;
25
+ }
26
+
27
+ export class PayloadCommerceBackend implements CommerceBackend {
28
+ constructor(
29
+ private readonly payload: Payload,
30
+ private readonly currency: string,
31
+ private readonly baseUrl: string,
32
+ private readonly siteConfig: SiteConfig,
33
+ ) {}
34
+
35
+ private toCart(doc: CartDoc): Cart {
36
+ return {
37
+ id: String(doc.id),
38
+ lines: doc.lines ?? [],
39
+ currency: doc.currency ?? this.currency,
40
+ subtotal: doc.subtotal ?? 0,
41
+ discountTotal: doc.discountTotal,
42
+ total: doc.total ?? 0,
43
+ };
44
+ }
45
+
46
+ private async persist(cart: Cart): Promise<Cart> {
47
+ await this.payload.update({
48
+ collection: 'carts',
49
+ id: cart.id,
50
+ data: { lines: cart.lines, subtotal: cart.subtotal, discountTotal: cart.discountTotal, total: cart.total },
51
+ });
52
+ return cart;
53
+ }
54
+
55
+ async createCart(): Promise<Cart> {
56
+ const doc = await this.payload.create({
57
+ collection: 'carts',
58
+ data: { lines: [], currency: this.currency, subtotal: 0, total: 0, status: 'active' },
59
+ });
60
+ return { ...emptyCart('', this.currency), id: String(doc.id) };
61
+ }
62
+
63
+ async getCart(cartId: string): Promise<Cart | null> {
64
+ const doc = await this.payload.findByID({ collection: 'carts', id: cartId }).catch(() => null);
65
+ return doc ? this.toCart(doc as unknown as CartDoc) : null;
66
+ }
67
+
68
+ async addItem(cartId: string, variantId: string, qty: number): Promise<Cart> {
69
+ const cart = (await this.getCart(cartId)) ?? emptyCart(cartId, this.currency);
70
+ const variant = (await this.payload.findByID({
71
+ collection: 'variants',
72
+ id: variantId,
73
+ depth: 1,
74
+ })) as unknown as VariantDoc;
75
+ const product = variant.product && typeof variant.product === 'object' ? (variant.product as ProductDoc) : null;
76
+ const firstImage =
77
+ product?.images && product.images.length > 0 && typeof product.images[0] === 'object'
78
+ ? ((product.images[0] as { url?: string }).url ?? undefined)
79
+ : undefined;
80
+
81
+ const next = addCartLine(cart, {
82
+ id: `${variantId}-${Date.now()}`,
83
+ variantId: String(variant.id),
84
+ productId: product ? String(product.id) : '',
85
+ title: product?.title ?? variant.sku,
86
+ unitPrice: variant.price,
87
+ quantity: qty,
88
+ image: firstImage,
89
+ });
90
+ return this.persist(next);
91
+ }
92
+
93
+ async updateItem(cartId: string, lineId: string, qty: number): Promise<Cart> {
94
+ const cart = await this.getCart(cartId);
95
+ if (!cart) throw new Error('[vitrine] cart not found');
96
+ return this.persist(setCartLineQty(cart, lineId, qty));
97
+ }
98
+
99
+ async removeItem(cartId: string, lineId: string): Promise<Cart> {
100
+ const cart = await this.getCart(cartId);
101
+ if (!cart) throw new Error('[vitrine] cart not found');
102
+ return this.persist(removeCartLine(cart, lineId));
103
+ }
104
+
105
+ /** Re-checks line prices against current variants (anti-stale). The price source
106
+ * is the DB, not the value saved in the cart at add time. */
107
+ private async reprice(cart: Cart): Promise<Cart> {
108
+ const lines = await Promise.all(
109
+ cart.lines.map(async (l) => {
110
+ const variant = (await this.payload
111
+ .findByID({ collection: 'variants', id: l.variantId })
112
+ .catch(() => null)) as unknown as VariantDoc | null;
113
+ return variant && typeof variant.price === 'number' ? { ...l, unitPrice: variant.price } : l;
114
+ }),
115
+ );
116
+ return this.persist(recalcCart({ ...cart, lines })); // recalc recomputes lineTotal/totals
117
+ }
118
+
119
+ async startCheckout(cartId: string): Promise<{ redirectUrl: string }> {
120
+ const current = await this.getCart(cartId);
121
+ if (!current || current.lines.length === 0) throw new Error('[vitrine] cart is empty');
122
+ const cart = await this.reprice(current);
123
+ // The active provider (Stripe/Paddle/YooKassa) is resolved by integrations.payments;
124
+ // its checkout-<provider> feature registered it in the registry via lib/payments.ts.
125
+ return payments.resolve(this.siteConfig).createCheckout({ cart, baseUrl: this.baseUrl });
126
+ }
127
+
128
+ async getOrder(id: string): Promise<Order | null> {
129
+ const doc = await this.payload.findByID({ collection: 'orders', id }).catch(() => null);
130
+ if (!doc) return null;
131
+ const o = doc as unknown as {
132
+ id: string | number;
133
+ status: Order['status'];
134
+ lines?: Order['lines'];
135
+ currency?: string;
136
+ subtotal?: number;
137
+ discountTotal?: number;
138
+ total?: number;
139
+ email?: string;
140
+ createdAt?: string;
141
+ };
142
+ return {
143
+ id: String(o.id),
144
+ status: o.status,
145
+ lines: o.lines ?? [],
146
+ currency: o.currency ?? this.currency,
147
+ subtotal: o.subtotal ?? 0,
148
+ discountTotal: o.discountTotal,
149
+ total: o.total ?? 0,
150
+ email: o.email,
151
+ createdAt: o.createdAt ?? new Date().toISOString(),
152
+ };
153
+ }
154
+ }
@@ -0,0 +1,50 @@
1
+ // Structural shapes of Payload documents as the local API returns them (with
2
+ // depth ≥ 1 relations expand into objects). Intentionally loose and do NOT import
3
+ // the generated payload-types — so that map.ts stays pure (contracts only)
4
+ // and testable without Payload.
5
+
6
+ export type Id = string | number;
7
+
8
+ export interface MediaDoc {
9
+ id: Id;
10
+ url?: string | null;
11
+ alt?: string | null;
12
+ width?: number | null;
13
+ height?: number | null;
14
+ }
15
+
16
+ export interface CategoryDoc {
17
+ id: Id;
18
+ slug: string;
19
+ title: string;
20
+ description?: string | null;
21
+ parent?: Id | CategoryDoc | null;
22
+ }
23
+
24
+ export interface VariantDoc {
25
+ id: Id;
26
+ sku: string;
27
+ price: number;
28
+ stock?: number | null;
29
+ options?: Record<string, string> | null;
30
+ product?: Id | ProductDoc | null;
31
+ }
32
+
33
+ export interface ProductSeoGroup {
34
+ title?: string | null;
35
+ description?: string | null;
36
+ image?: Id | MediaDoc | null;
37
+ }
38
+
39
+ export interface ProductDoc {
40
+ id: Id;
41
+ slug: string;
42
+ title: string;
43
+ /** Lexical richText (or string). Mapped to plain text. */
44
+ description?: unknown;
45
+ categories?: Array<Id | CategoryDoc> | null;
46
+ images?: Array<Id | MediaDoc> | null;
47
+ seo?: ProductSeoGroup | null;
48
+ /** Fields added by features via blueprint extend() → Product.extensions. */
49
+ [key: string]: unknown;
50
+ }
@@ -0,0 +1,23 @@
1
+ // Dev admin (§18.3): dev only + empty users collection. Password from
2
+ // DEV_ADMIN_PASSWORD, otherwise random (no well-known default), printed
3
+ // to the console once.
4
+ import { randomBytes } from 'node:crypto';
5
+ import type { Payload } from 'payload';
6
+ import { shouldRunDevTask } from './guards.js';
7
+
8
+ function randomPassword(length = 16): string {
9
+ return randomBytes(length).toString('base64url').slice(0, length);
10
+ }
11
+
12
+ export async function ensureDevAdmin(payload: Payload): Promise<void> {
13
+ const isProd = process.env.NODE_ENV === 'production';
14
+ const { totalDocs } = await payload.count({ collection: 'users' });
15
+ if (!shouldRunDevTask({ isProd, existingCount: totalDocs })) return;
16
+
17
+ const email = process.env.DEV_ADMIN_EMAIL ?? 'admin@dev.local';
18
+ const password = process.env.DEV_ADMIN_PASSWORD ?? randomPassword();
19
+ await payload.create({ collection: 'users', data: { email, password } });
20
+ payload.logger.warn(
21
+ `[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
22
+ );
23
+ }
@@ -0,0 +1,79 @@
1
+ // Demo data for zero-config dev (§18.2): 5 products + 2 categories + local
2
+ // placeholder images (seed-assets/). Prices are in minor units (e.g. cents):
3
+ // 199000 = 1990.00. Pure data — covered by an invariants test.
4
+
5
+ export interface DemoCategory {
6
+ slug: string;
7
+ title: string;
8
+ }
9
+
10
+ export interface DemoVariant {
11
+ sku: string;
12
+ /** Minor units (e.g. cents). */
13
+ price: number;
14
+ stock: number;
15
+ }
16
+
17
+ export interface DemoProduct {
18
+ slug: string;
19
+ title: string;
20
+ category: string;
21
+ description: string;
22
+ /** File name in seed-assets/. */
23
+ image: string;
24
+ seo: { title: string; description: string };
25
+ variants: DemoVariant[];
26
+ }
27
+
28
+ export const demoCategories: DemoCategory[] = [
29
+ { slug: 'apparel', title: 'Apparel' },
30
+ { slug: 'accessories', title: 'Accessories' },
31
+ ];
32
+
33
+ export const demoProducts: DemoProduct[] = [
34
+ {
35
+ slug: 'classic-tee',
36
+ title: 'Classic T-Shirt',
37
+ category: 'apparel',
38
+ description: 'A straight-cut cotton T-shirt.',
39
+ image: 'placeholder-1.svg',
40
+ seo: { title: 'Classic T-Shirt', description: 'A basic cotton T-shirt.' },
41
+ variants: [{ sku: 'TEE-001', price: 199000, stock: 25 }],
42
+ },
43
+ {
44
+ slug: 'zip-hoodie',
45
+ title: 'Zip Hoodie',
46
+ category: 'apparel',
47
+ description: 'A warm hooded sweatshirt.',
48
+ image: 'placeholder-2.svg',
49
+ seo: { title: 'Zip Hoodie', description: 'An insulated hoodie.' },
50
+ variants: [{ sku: 'HOD-001', price: 459000, stock: 12 }],
51
+ },
52
+ {
53
+ slug: 'logo-cap',
54
+ title: 'Logo Cap',
55
+ category: 'accessories',
56
+ description: 'An adjustable cap.',
57
+ image: 'placeholder-3.svg',
58
+ seo: { title: 'Cap', description: 'A cap with a logo.' },
59
+ variants: [{ sku: 'CAP-001', price: 129000, stock: 40 }],
60
+ },
61
+ {
62
+ slug: 'tote-bag',
63
+ title: 'Tote Bag',
64
+ category: 'accessories',
65
+ description: 'A sturdy cotton tote.',
66
+ image: 'placeholder-4.svg',
67
+ seo: { title: 'Tote', description: 'A cotton tote bag.' },
68
+ variants: [{ sku: 'TOT-001', price: 99000, stock: 60 }],
69
+ },
70
+ {
71
+ slug: 'crew-socks',
72
+ title: 'Socks, 3 Pairs',
73
+ category: 'apparel',
74
+ description: 'A set of three pairs.',
75
+ image: 'placeholder-5.svg',
76
+ seo: { title: 'Socks', description: 'A pack of socks, 3 pairs.' },
77
+ variants: [{ sku: 'SOC-001', price: 79000, stock: 100 }],
78
+ },
79
+ ];
@@ -0,0 +1,5 @@
1
+ // Pure guards for zero-config dev procedures (§18). The seed and dev admin run
2
+ // ONLY in dev AND when the collection is empty — idempotent and never in prod.
3
+ export function shouldRunDevTask(opts: { isProd: boolean; existingCount: number }): boolean {
4
+ return !opts.isProd && opts.existingCount === 0;
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);