@vitrine-kit/vitrine 0.4.1 → 0.5.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 (125) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +114 -44
  3. package/kit/registry/_index.json +70 -9
  4. package/kit/registry/accounts/docs/accounts.md +20 -0
  5. package/kit/registry/accounts/feature.json +55 -0
  6. package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
  7. package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
  8. package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
  9. package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
  10. package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
  11. package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
  12. package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
  13. package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
  14. package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
  15. package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
  16. package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
  17. package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
  18. package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
  19. package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
  20. package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
  21. package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
  22. package/kit/registry/cart/feature.json +1 -1
  23. package/kit/registry/cart/files/app/api/cart/route.ts +101 -11
  24. package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
  25. package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
  26. package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
  27. package/kit/registry/cart/files/components/cart/CartView.tsx +21 -1
  28. package/kit/registry/catalog/feature.json +1 -1
  29. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
  30. package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
  31. package/kit/registry/checkout/docs/checkout.md +19 -1
  32. package/kit/registry/checkout/feature.json +1 -1
  33. package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
  34. package/kit/registry/checkout/files/app/api/checkout/route.ts +25 -4
  35. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
  36. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
  37. package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
  38. package/kit/registry/checkout-paddle/feature.json +1 -1
  39. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
  40. package/kit/registry/checkout-stripe/feature.json +1 -1
  41. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
  42. package/kit/registry/checkout-yookassa/feature.json +1 -1
  43. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
  44. package/kit/registry/email/docs/email.md +19 -0
  45. package/kit/registry/email/feature.json +60 -0
  46. package/kit/registry/email/files/lib/email/adapter.ts +89 -0
  47. package/kit/registry/filters/docs/filters.md +13 -0
  48. package/kit/registry/filters/feature.json +45 -0
  49. package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
  50. package/kit/registry/filters/files/lib/filters/register.ts +6 -0
  51. package/kit/registry/i18n/docs/i18n.md +24 -0
  52. package/kit/registry/i18n/feature.json +47 -0
  53. package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
  54. package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
  55. package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
  56. package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
  57. package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
  58. package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
  59. package/kit/registry/i18n/files/middleware.ts +51 -0
  60. package/kit/registry/product-page/feature.json +1 -1
  61. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
  62. package/kit/registry/product-page/files/components/product/ProductView.tsx +8 -4
  63. package/kit/registry/reviews/docs/reviews.md +11 -0
  64. package/kit/registry/reviews/feature.json +55 -0
  65. package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
  66. package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
  67. package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
  68. package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
  69. package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
  70. package/kit/registry/search/docs/search.md +12 -0
  71. package/kit/registry/search/feature.json +49 -0
  72. package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
  73. package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
  74. package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
  75. package/kit/registry/search/files/lib/search/data.ts +8 -0
  76. package/kit/registry/search/files/lib/search/register.ts +8 -0
  77. package/kit/registry/seo/docs/seo.md +4 -1
  78. package/kit/registry/seo/feature.json +5 -1
  79. package/kit/registry/seo/files/app/robots.ts +13 -0
  80. package/kit/registry/seo/files/app/sitemap.ts +27 -0
  81. package/kit/registry/stubs/route-glue.d.ts +71 -0
  82. package/kit/registry/tsconfig.json +7 -2
  83. package/kit/registry/wishlist/docs/wishlist.md +11 -0
  84. package/kit/registry/wishlist/feature.json +55 -0
  85. package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
  86. package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
  87. package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
  88. package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
  89. package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
  90. package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
  91. package/kit/templates/backend-payload/files/Dockerfile +3 -0
  92. package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
  93. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
  94. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
  95. package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
  96. package/kit/templates/backend-payload/files/lib/seed/admin.ts +16 -6
  97. package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
  98. package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
  99. package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
  100. package/kit/templates/backend-payload/files/lib/seed/run.ts +92 -16
  101. package/kit/templates/backend-payload/files/next.config.mjs +34 -0
  102. package/kit/templates/backend-payload/files/payload.config.ts +33 -3
  103. package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
  104. package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
  105. package/kit/templates/backend-vendure/files/Dockerfile +1 -1
  106. package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
  107. package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
  108. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
  109. package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
  110. package/kit/templates/base/files/.claude/commands/update.md +1 -1
  111. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
  112. package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
  113. package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
  114. package/kit/templates/base/files/app/(frontend)/page.tsx +84 -6
  115. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
  116. package/kit/templates/base/files/app/globals.css +13 -0
  117. package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
  118. package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
  119. package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
  120. package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
  121. package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
  122. package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
  123. package/kit/templates/base/files/lib/site.ts +3 -0
  124. package/kit/templates/base/files/next.config.mjs +34 -0
  125. package/package.json +2 -2
@@ -1,10 +1,12 @@
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.
1
+ // Demo data for zero-config dev (§18.2): a small but realistic catalog so a
2
+ // freshly scaffolded shop has browseable examples (categories, variants,
3
+ // gallery images, SEO). Prices are integer minor units (cents): 2490 = $24.90.
4
+ // Pure data — covered by invariants tests in sandbox.
4
5
 
5
6
  export interface DemoCategory {
6
7
  slug: string;
7
8
  title: string;
9
+ description: string;
8
10
  }
9
11
 
10
12
  export interface DemoVariant {
@@ -12,6 +14,8 @@ export interface DemoVariant {
12
14
  /** Minor units (e.g. cents). */
13
15
  price: number;
14
16
  stock: number;
17
+ /** e.g. { size: 'M', color: 'Black' }. */
18
+ options?: Record<string, string>;
15
19
  }
16
20
 
17
21
  export interface DemoProduct {
@@ -19,15 +23,23 @@ export interface DemoProduct {
19
23
  title: string;
20
24
  category: string;
21
25
  description: string;
22
- /** File name in seed-assets/. */
23
- image: string;
26
+ /** File names in seed-assets/ (first = cover; extras feed the gallery). */
27
+ images: string[];
24
28
  seo: { title: string; description: string };
25
29
  variants: DemoVariant[];
26
30
  }
27
31
 
28
32
  export const demoCategories: DemoCategory[] = [
29
- { slug: 'apparel', title: 'Apparel' },
30
- { slug: 'accessories', title: 'Accessories' },
33
+ {
34
+ slug: 'apparel',
35
+ title: 'Apparel',
36
+ description: 'Everyday layers — tees, hoodies, and socks with size options you can add to the cart.',
37
+ },
38
+ {
39
+ slug: 'accessories',
40
+ title: 'Accessories',
41
+ description: 'Caps and bags that round out a simple merch assortment.',
42
+ },
31
43
  ];
32
44
 
33
45
  export const demoProducts: DemoProduct[] = [
@@ -35,45 +47,79 @@ export const demoProducts: DemoProduct[] = [
35
47
  slug: 'classic-tee',
36
48
  title: 'Classic T-Shirt',
37
49
  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 }],
50
+ description:
51
+ 'A straight-cut midweight cotton tee for daily wear. Soft hand-feel, reinforced shoulder seams, and a slightly longer hem that stays tucked. Pick a size below, add it to your cart, then open /cart to try checkout.',
52
+ images: ['placeholder-1.svg', 'placeholder-1b.svg'],
53
+ seo: {
54
+ title: 'Classic T-Shirt — midweight cotton',
55
+ description: 'Demo apparel product with multiple sizes. Midweight cotton, straight cut.',
56
+ },
57
+ variants: [
58
+ { sku: 'TEE-S', price: 2490, stock: 18, options: { size: 'S' } },
59
+ { sku: 'TEE-M', price: 2490, stock: 32, options: { size: 'M' } },
60
+ { sku: 'TEE-L', price: 2490, stock: 21, options: { size: 'L' } },
61
+ { sku: 'TEE-XL', price: 2690, stock: 9, options: { size: 'XL' } },
62
+ ],
42
63
  },
43
64
  {
44
65
  slug: 'zip-hoodie',
45
66
  title: 'Zip Hoodie',
46
67
  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 }],
68
+ description:
69
+ 'A brushed-fleece zip hoodie with a roomy hood and metal zipper. Wear it open over the Classic Tee or zipped for cooler evenings. Sizes share the same cut; XL is a touch longer in the body.',
70
+ images: ['placeholder-2.svg', 'placeholder-2b.svg'],
71
+ seo: {
72
+ title: 'Zip Hoodie — brushed fleece',
73
+ description: 'Demo hoodie with size variants and a two-image gallery.',
74
+ },
75
+ variants: [
76
+ { sku: 'HOD-S', price: 6900, stock: 8, options: { size: 'S' } },
77
+ { sku: 'HOD-M', price: 6900, stock: 14, options: { size: 'M' } },
78
+ { sku: 'HOD-L', price: 6900, stock: 11, options: { size: 'L' } },
79
+ { sku: 'HOD-XL', price: 7200, stock: 4, options: { size: 'XL' } },
80
+ ],
51
81
  },
52
82
  {
53
83
  slug: 'logo-cap',
54
84
  title: 'Logo Cap',
55
85
  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 }],
86
+ description:
87
+ 'A structured six-panel cap with an adjustable strap. One size fits most; choose color to see how option-based variants appear on accessories.',
88
+ images: ['placeholder-3.svg'],
89
+ seo: {
90
+ title: 'Logo Cap — adjustable',
91
+ description: 'Demo accessory with color options.',
92
+ },
93
+ variants: [
94
+ { sku: 'CAP-BLK', price: 2900, stock: 24, options: { color: 'Black' } },
95
+ { sku: 'CAP-NAV', price: 2900, stock: 16, options: { color: 'Navy' } },
96
+ { sku: 'CAP-SND', price: 2900, stock: 12, options: { color: 'Sand' } },
97
+ ],
60
98
  },
61
99
  {
62
100
  slug: 'tote-bag',
63
101
  title: 'Tote Bag',
64
102
  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 }],
103
+ description:
104
+ 'A heavyweight canvas tote with an interior pocket. Single SKU — useful for comparing a simple product against multi-variant apparel on the product page.',
105
+ images: ['placeholder-4.svg'],
106
+ seo: {
107
+ title: 'Tote Bag — heavyweight canvas',
108
+ description: 'Demo single-variant accessory for a simple add-to-cart path.',
109
+ },
110
+ variants: [{ sku: 'TOT-001', price: 1800, stock: 60 }],
69
111
  },
70
112
  {
71
113
  slug: 'crew-socks',
72
- title: 'Socks, 3 Pairs',
114
+ title: 'Crew Socks (3-Pack)',
73
115
  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 }],
116
+ description:
117
+ 'A three-pair pack of ribbed crew socks. Soft cuff, reinforced heel and toe. Sold as one pack SKU so cart lines stay simple while you explore quantity updates on /cart.',
118
+ images: ['placeholder-5.svg'],
119
+ seo: {
120
+ title: 'Crew Socks — 3-pack',
121
+ description: 'Demo pack product with a single variant and high stock.',
122
+ },
123
+ variants: [{ sku: 'SOC-3PK', price: 1400, stock: 100 }],
78
124
  },
79
125
  ];
@@ -1,5 +1,18 @@
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;
1
+ // Pure guards for zero-config bootstrap (§18). Seed and dev admin run when the
2
+ // collection is empty — in development always, and in production only when
3
+ // SEED_ON_BOOT=1 (docker compose demos / first boot).
4
+ export function shouldRunDevTask(opts: {
5
+ isProd: boolean;
6
+ existingCount: number;
7
+ /** When true, allow empty-collection bootstrap even in production. */
8
+ seedOnBoot?: boolean;
9
+ }): boolean {
10
+ if (opts.existingCount !== 0) return false;
11
+ if (!opts.isProd) return true;
12
+ return Boolean(opts.seedOnBoot);
13
+ }
14
+
15
+ export function seedOnBootEnabled(): boolean {
16
+ const v = process.env.SEED_ON_BOOT?.trim().toLowerCase();
17
+ return v === '1' || v === 'true' || v === 'yes';
5
18
  }
@@ -0,0 +1,53 @@
1
+ // Optional non-default locale copy for the demo seed when Payload localization is enabled.
2
+ // Slugs stay shared; only human-readable fields differ.
3
+
4
+ export const demoLocales = {
5
+ categories: {
6
+ apparel: {
7
+ ru: {
8
+ title: 'Одежда',
9
+ description: 'Повседневные слои — футболки, худи и носки с выбором размера.',
10
+ },
11
+ },
12
+ accessories: {
13
+ ru: {
14
+ title: 'Аксессуары',
15
+ description: 'Кепки и сумки, которые дополняют простой мерч-ассортимент.',
16
+ },
17
+ },
18
+ },
19
+ products: {
20
+ 'classic-tee': {
21
+ ru: {
22
+ title: 'Классическая футболка',
23
+ description:
24
+ 'Прямой крой из хлопка средней плотности на каждый день. Мягкая фактура, усиленные плечевые швы. Выберите размер ниже и добавьте в корзину.',
25
+ seo: {
26
+ title: 'Классическая футболка — хлопок',
27
+ description: 'Демо-товар одежды с несколькими размерами.',
28
+ },
29
+ },
30
+ },
31
+ 'zip-hoodie': {
32
+ ru: {
33
+ title: 'Худи на молнии',
34
+ description:
35
+ 'Флисовое худи на молнии с объёмным капюшоном. Носите поверх футболки или застёгнутым в прохладный вечер.',
36
+ seo: {
37
+ title: 'Худи на молнии — флис',
38
+ description: 'Демо-худи с размерами и галереей.',
39
+ },
40
+ },
41
+ },
42
+ 'logo-cap': {
43
+ ru: {
44
+ title: 'Кепка с логотипом',
45
+ description: 'Структурированная кепка с регулируемой застёжкой. Выберите цвет и добавьте в корзину.',
46
+ seo: {
47
+ title: 'Кепка с логотипом',
48
+ description: 'Демо-аксессуар с цветовыми вариантами.',
49
+ },
50
+ },
51
+ },
52
+ },
53
+ } as const;
@@ -1,32 +1,75 @@
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).
1
+ // Demo seed (§18.2): runs from Payload's onInit. Idempotent (empty collection +
2
+ // dev, or SEED_ON_BOOT=1 in production). Images are local placeholders from
3
+ // seed-assets/ (no network). Optional RU copy is applied when localization is on.
4
+ import { existsSync } from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import type { Payload } from 'payload';
7
8
  import { demoCategories, demoProducts } from './demo.js';
8
- import { shouldRunDevTask } from './guards.js';
9
+ import { seedOnBootEnabled, shouldRunDevTask } from './guards.js';
10
+ import { demoLocales } from './locales.js';
9
11
  import { plainToRichText } from './richtext.js';
10
12
 
11
- const seedAssets = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../seed-assets');
13
+ // Prefer cwd/seed-assets so Docker runtime (standalone) can ship the folder next to server.js.
14
+ const seedAssets = [
15
+ path.resolve(process.cwd(), 'seed-assets'),
16
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../seed-assets'),
17
+ ].find((p) => existsSync(p));
12
18
 
13
19
  export async function seedDemo(payload: Payload): Promise<void> {
14
20
  const isProd = process.env.NODE_ENV === 'production';
15
21
  const { totalDocs } = await payload.count({ collection: 'products' });
16
- if (!shouldRunDevTask({ isProd, existingCount: totalDocs })) return;
22
+ if (!shouldRunDevTask({ isProd, existingCount: totalDocs, seedOnBoot: seedOnBootEnabled() })) {
23
+ return;
24
+ }
25
+ if (!seedAssets) {
26
+ payload.logger.warn('[vitrine] demo seed skipped — seed-assets/ not found');
27
+ return;
28
+ }
29
+
30
+ const localization = payload.config.localization;
31
+ const defaultLocale = localization?.defaultLocale ?? 'en';
32
+ const enabledLocales = new Set(
33
+ (localization?.locales ?? []).map((l) => (typeof l === 'string' ? l : l.code)),
34
+ );
35
+ const createOpts = localization ? { locale: defaultLocale as 'en' } : {};
17
36
 
18
37
  const categoryId = new Map<string, string | number>();
19
38
  for (const c of demoCategories) {
20
- const doc = await payload.create({ collection: 'categories', data: { slug: c.slug, title: c.title } });
39
+ const doc = await payload.create({
40
+ collection: 'categories',
41
+ data: { slug: c.slug, title: c.title, description: c.description },
42
+ ...createOpts,
43
+ overrideAccess: true,
44
+ });
21
45
  categoryId.set(c.slug, doc.id);
46
+ const localized = demoLocales.categories[c.slug];
47
+ if (localized && localization) {
48
+ for (const [locale, copy] of Object.entries(localized)) {
49
+ if (locale === defaultLocale || !enabledLocales.has(locale)) continue;
50
+ await payload.update({
51
+ collection: 'categories',
52
+ id: doc.id,
53
+ data: { title: copy.title, description: copy.description },
54
+ locale: locale as 'en',
55
+ overrideAccess: true,
56
+ });
57
+ }
58
+ }
22
59
  }
23
60
 
24
61
  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
- });
62
+ const mediaIds: Array<string | number> = [];
63
+ for (const [index, image] of p.images.entries()) {
64
+ const media = await payload.create({
65
+ collection: 'media',
66
+ data: { alt: index === 0 ? p.title : `${p.title} — detail` },
67
+ filePath: path.join(seedAssets, image),
68
+ overrideAccess: true,
69
+ });
70
+ mediaIds.push(media.id);
71
+ }
72
+ const coverId = mediaIds[0];
30
73
  const catId = categoryId.get(p.category);
31
74
  const product = await payload.create({
32
75
  collection: 'products',
@@ -35,17 +78,50 @@ export async function seedDemo(payload: Payload): Promise<void> {
35
78
  title: p.title,
36
79
  description: plainToRichText(p.description),
37
80
  categories: catId ? [catId] : [],
38
- images: [media.id],
39
- seo: { title: p.seo.title, description: p.seo.description, image: media.id },
81
+ images: mediaIds,
82
+ seo: {
83
+ title: p.seo.title,
84
+ description: p.seo.description,
85
+ image: coverId,
86
+ },
40
87
  },
88
+ ...createOpts,
89
+ overrideAccess: true,
41
90
  });
91
+ const localized = demoLocales.products[p.slug];
92
+ if (localized && localization) {
93
+ for (const [locale, copy] of Object.entries(localized)) {
94
+ if (locale === defaultLocale || !enabledLocales.has(locale)) continue;
95
+ await payload.update({
96
+ collection: 'products',
97
+ id: product.id,
98
+ data: {
99
+ title: copy.title,
100
+ description: plainToRichText(copy.description),
101
+ seo: { title: copy.seo.title, description: copy.seo.description },
102
+ },
103
+ locale: locale as 'en',
104
+ overrideAccess: true,
105
+ });
106
+ }
107
+ }
42
108
  for (const v of p.variants) {
43
109
  await payload.create({
44
110
  collection: 'variants',
45
- data: { sku: v.sku, price: v.price, stock: v.stock, product: product.id },
111
+ data: {
112
+ sku: v.sku,
113
+ price: v.price,
114
+ stock: v.stock,
115
+ product: product.id,
116
+ ...(v.options ? { options: v.options } : {}),
117
+ },
118
+ overrideAccess: true,
46
119
  });
47
120
  }
48
121
  }
49
122
 
50
- payload.logger.info('[vitrine] demo seed: created 5 products');
123
+ const variantCount = demoProducts.reduce((n, p) => n + p.variants.length, 0);
124
+ payload.logger.info(
125
+ `[vitrine] demo seed: ${demoProducts.length} products, ${demoCategories.length} categories, ${variantCount} variants — browse / then try Add to cart`,
126
+ );
51
127
  }
@@ -2,9 +2,43 @@
2
2
  // API into Next). output: 'standalone' — for the Docker image (VPS).
3
3
  import { withPayload } from '@payloadcms/next/withPayload';
4
4
 
5
+ // Baseline security headers (OWASP A05: Security Misconfiguration) applied to every route.
6
+ // The CSP below is a conservative starting point — payment providers with client-side widgets
7
+ // (e.g. embedded Stripe Elements) may need their domains added to script-src/connect-src/frame-src.
8
+ // Next.js `pnpm dev` (webpack eval source maps / Fast Refresh) needs 'unsafe-eval'; omit it in
9
+ // production builds so the shipped CSP stays tighter.
10
+ const isDev = process.env.NODE_ENV !== 'production';
11
+ const scriptSrc = isDev
12
+ ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
13
+ : "script-src 'self' 'unsafe-inline'";
14
+
15
+ const securityHeaders = [
16
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
17
+ { key: 'X-Frame-Options', value: 'DENY' },
18
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
19
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
20
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
21
+ {
22
+ key: 'Content-Security-Policy',
23
+ value: `default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';`,
24
+ },
25
+ ];
26
+
5
27
  /** @type {import('next').NextConfig} */
6
28
  const nextConfig = {
7
29
  output: 'standalone',
30
+ // Registry/template sources use TypeScript ESM-style relative imports (`.js` → `.ts`/`.tsx`).
31
+ // Webpack needs an explicit extensionAlias; without it, `pnpm dev` fails to resolve components.
32
+ webpack(config) {
33
+ config.resolve.extensionAlias = {
34
+ ...(config.resolve.extensionAlias ?? {}),
35
+ '.js': ['.ts', '.tsx', '.js', '.jsx'],
36
+ };
37
+ return config;
38
+ },
39
+ async headers() {
40
+ return [{ source: '/:path*', headers: securityHeaders }];
41
+ },
8
42
  };
9
43
 
10
44
  export default withPayload(nextConfig);
@@ -1,19 +1,47 @@
1
1
  // The client's Payload 3 config. Collections are assembled by the blueprint (base +
2
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).
3
+ // chosen zero-config (db.ts → §18.1). onInit runs the demo seed and boot admin.
4
+ // Email adapter is optional — present when the `email` feature is installed.
5
+ // Localization mirrors site.config i18n when more than one locale is configured.
4
6
  import path from 'node:path';
5
7
  import { fileURLToPath } from 'node:url';
6
- import { buildConfig } from 'payload';
7
8
  import type { CollectionConfig } from 'payload';
9
+ import { buildConfig } from 'payload';
8
10
  import { lexicalEditor } from '@payloadcms/richtext-lexical';
9
11
  import sharp from 'sharp';
10
12
  import { collections } from './lib/blueprint.js';
11
13
  import { resolveDbAdapter } from './lib/adapter/db.js';
12
14
  import { seedDemo } from './lib/seed/run.js';
13
15
  import { ensureDevAdmin } from './lib/seed/admin.js';
16
+ import { siteConfig } from './site.config.js';
14
17
 
15
18
  const dirname = path.dirname(fileURLToPath(import.meta.url));
16
19
 
20
+ // Empty string from `cp .env.example .env` must not count as set (?? only skips null/undefined).
21
+ const payloadSecret = process.env.PAYLOAD_SECRET?.trim() || undefined;
22
+ if (!payloadSecret && process.env.NODE_ENV === 'production') {
23
+ // No silent fallback to the dev secret in production (mirrors the Vendure DATABASE_URL guard).
24
+ throw new Error('[vitrine] PAYLOAD_SECRET is required in production');
25
+ }
26
+
27
+ async function loadEmailAdapter(): Promise<unknown> {
28
+ try {
29
+ const mod = await import('./lib/email/adapter.js');
30
+ return await mod.resolveEmailAdapter();
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ const email = await loadEmailAdapter();
37
+
38
+ const locales = siteConfig.i18n.locales.filter(Boolean);
39
+ const localization = {
40
+ locales: locales.length > 0 ? locales : ['en'],
41
+ defaultLocale: siteConfig.i18n.defaultLocale || locales[0] || 'en',
42
+ fallback: true,
43
+ };
44
+
17
45
  export default buildConfig({
18
46
  admin: {
19
47
  user: 'users',
@@ -21,9 +49,11 @@ export default buildConfig({
21
49
  },
22
50
  collections: collections as unknown as CollectionConfig[],
23
51
  editor: lexicalEditor(),
24
- secret: process.env.PAYLOAD_SECRET ?? 'dev-secret-change-me',
52
+ secret: payloadSecret ?? 'dev-secret-change-me',
25
53
  db: await resolveDbAdapter(),
26
54
  sharp,
55
+ localization,
56
+ ...(email ? { email: email as never } : {}),
27
57
  typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
28
58
  async onInit(payload) {
29
59
  await seedDemo(payload);
@@ -0,0 +1,6 @@
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 detail">
2
+ <rect width="600" height="600" fill="#f5f5f4"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#e7e5e4"/>
4
+ <text x="300" y="290" font-family="system-ui, sans-serif" font-size="36" fill="#57534e" text-anchor="middle">T-Shirt</text>
5
+ <text x="300" y="340" font-family="system-ui, sans-serif" font-size="22" fill="#78716c" text-anchor="middle">detail view</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Zip Hoodie detail">
2
+ <rect width="600" height="600" fill="#fafaf9"/>
3
+ <rect x="40" y="40" width="520" height="520" rx="16" fill="#e7e5e4"/>
4
+ <text x="300" y="290" font-family="system-ui, sans-serif" font-size="36" fill="#44403c" text-anchor="middle">Hoodie</text>
5
+ <text x="300" y="340" font-family="system-ui, sans-serif" font-size="22" fill="#78716c" text-anchor="middle">detail view</text>
6
+ </svg>
@@ -1,6 +1,6 @@
1
1
  # syntax=docker/dockerfile:1
2
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.
3
+ # (`pnpm exec tsx src/index.ts`) and the Next storefront (`pnpm start`). Target — VPS.
4
4
  FROM node:20-alpine AS base
5
5
  RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
6
6
  WORKDIR /app
@@ -20,7 +20,8 @@ services:
20
20
  build:
21
21
  context: .
22
22
  restart: unless-stopped
23
- command: ['node', 'dist/index.js']
23
+ # tsx is available from the full build image; avoids a separate server compile step.
24
+ command: ['pnpm', 'exec', 'tsx', 'src/index.ts']
24
25
  environment:
25
26
  NODE_ENV: production
26
27
  DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
@@ -0,0 +1,126 @@
1
+ // Pure ProductQuery helpers — filter/sort in memory after the adapter loads products.
2
+ // Price lives on variants, so Payload cannot ORDER BY price in SQL without a denormalized
3
+ // field; this keeps the contract honest for catalog-sized datasets.
4
+ import type { Product, ProductQuery, ProductSort } from '@vitrine-kit/contracts';
5
+
6
+ export function parseProductSort(value: string | undefined | null): ProductSort | undefined {
7
+ if (value === 'newest' || value === 'price-asc' || value === 'price-desc' || value === 'relevance') {
8
+ return value;
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ /** Parse `?size=S&size=L&color=Black` style params into ProductQuery.filters. */
14
+ export function parseProductFilters(
15
+ params: Record<string, string | string[] | undefined>,
16
+ facetKeys: string[] = ['size', 'color'],
17
+ ): Record<string, string[]> | undefined {
18
+ const filters: Record<string, string[]> = {};
19
+ for (const key of facetKeys) {
20
+ const raw = params[key];
21
+ if (raw == null) continue;
22
+ const values = (Array.isArray(raw) ? raw : [raw])
23
+ .flatMap((v) => String(v).split(','))
24
+ .map((v) => v.trim())
25
+ .filter(Boolean);
26
+ if (values.length) filters[key] = [...new Set(values)];
27
+ }
28
+ return Object.keys(filters).length ? filters : undefined;
29
+ }
30
+
31
+ export function parsePriceBound(
32
+ value: string | string[] | undefined | null,
33
+ ): number | undefined {
34
+ const raw = Array.isArray(value) ? value[0] : value;
35
+ if (raw == null || raw === '') return undefined;
36
+ const n = Number(raw);
37
+ if (!Number.isFinite(n) || n < 0) return undefined;
38
+ // URL values are major currency units ($); ProductQuery uses minor units.
39
+ return Math.round(n * 100);
40
+ }
41
+
42
+ /** Aggregate option values present on the given products (for toolbar facets). */
43
+ export function collectOptionFacets(products: Product[]): Record<string, string[]> {
44
+ const buckets = new Map<string, Set<string>>();
45
+ for (const p of products) {
46
+ for (const v of p.variants) {
47
+ if (!v.options) continue;
48
+ for (const [key, val] of Object.entries(v.options)) {
49
+ if (!val) continue;
50
+ let set = buckets.get(key);
51
+ if (!set) {
52
+ set = new Set();
53
+ buckets.set(key, set);
54
+ }
55
+ set.add(val);
56
+ }
57
+ }
58
+ }
59
+ const out: Record<string, string[]> = {};
60
+ for (const [key, set] of [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b))) {
61
+ out[key] = [...set].sort((a, b) => a.localeCompare(b));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function matchesFilters(product: Product, filters: Record<string, string[]>): boolean {
67
+ for (const [key, wanted] of Object.entries(filters)) {
68
+ if (!wanted.length) continue;
69
+ const has = product.variants.some((v) => {
70
+ const actual = v.options?.[key];
71
+ return actual != null && wanted.includes(actual);
72
+ });
73
+ if (!has) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ function matchesSearch(product: Product, term: string): boolean {
79
+ const q = term.trim().toLowerCase();
80
+ if (!q) return true;
81
+ const hay = `${product.title} ${product.description ?? ''} ${product.slug}`.toLowerCase();
82
+ return hay.includes(q);
83
+ }
84
+
85
+ function priceOf(product: Product): number {
86
+ return product.priceRange?.min ?? product.variants[0]?.price ?? Number.POSITIVE_INFINITY;
87
+ }
88
+
89
+ function matchesPrice(product: Product, priceMin?: number, priceMax?: number): boolean {
90
+ if (priceMin == null && priceMax == null) return true;
91
+ // A product matches if any variant price falls in range (merchandising expectation).
92
+ return product.variants.some((v) => {
93
+ if (priceMin != null && v.price < priceMin) return false;
94
+ if (priceMax != null && v.price > priceMax) return false;
95
+ return true;
96
+ });
97
+ }
98
+
99
+ /** Apply search / filters / sort that the DB layer cannot express cheaply. */
100
+ export function applyProductQuery(products: Product[], query: ProductQuery = {}): Product[] {
101
+ let out = products;
102
+ if (query.search?.trim()) {
103
+ out = out.filter((p) => matchesSearch(p, query.search!));
104
+ }
105
+ if (query.filters && Object.keys(query.filters).length > 0) {
106
+ out = out.filter((p) => matchesFilters(p, query.filters!));
107
+ }
108
+ if (query.priceMin != null || query.priceMax != null) {
109
+ out = out.filter((p) => matchesPrice(p, query.priceMin, query.priceMax));
110
+ }
111
+ const sort = query.sort ?? 'newest';
112
+ if (sort === 'price-asc') {
113
+ out = [...out].sort((a, b) => priceOf(a) - priceOf(b));
114
+ } else if (sort === 'price-desc') {
115
+ out = [...out].sort((a, b) => priceOf(b) - priceOf(a));
116
+ } else if (sort === 'relevance' && query.search?.trim()) {
117
+ const q = query.search.trim().toLowerCase();
118
+ out = [...out].sort((a, b) => {
119
+ const aTitle = a.title.toLowerCase().startsWith(q) ? 0 : a.title.toLowerCase().includes(q) ? 1 : 2;
120
+ const bTitle = b.title.toLowerCase().startsWith(q) ? 0 : b.title.toLowerCase().includes(q) ? 1 : 2;
121
+ return aTitle - bTitle || a.title.localeCompare(b.title);
122
+ });
123
+ }
124
+ // newest: keep adapter order (typically -createdAt)
125
+ return out;
126
+ }