@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
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import type { Product } from '@vitrine-kit/contracts';
4
+ import { isWishlisted, toggleWishlist } from '../../lib/wishlist/storage.js';
5
+
6
+ export interface WishlistButtonProps {
7
+ product?: Product;
8
+ }
9
+
10
+ export function WishlistButton({ product }: WishlistButtonProps) {
11
+ const slug = product?.slug;
12
+ const [on, setOn] = useState(false);
13
+
14
+ useEffect(() => {
15
+ if (!slug) return;
16
+ setOn(isWishlisted(slug));
17
+ const sync = () => setOn(isWishlisted(slug));
18
+ window.addEventListener('vitrine:wishlist', sync);
19
+ window.addEventListener('storage', sync);
20
+ return () => {
21
+ window.removeEventListener('vitrine:wishlist', sync);
22
+ window.removeEventListener('storage', sync);
23
+ };
24
+ }, [slug]);
25
+
26
+ if (!slug) return null;
27
+
28
+ return (
29
+ <button
30
+ type="button"
31
+ className="vt-wishlist-button w-fit text-sm text-muted-fg underline underline-offset-2 transition hover:text-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
32
+ onClick={() => setOn(toggleWishlist(slug).includes(slug))}
33
+ >
34
+ {on ? 'Remove from wishlist' : 'Add to wishlist'}
35
+ </button>
36
+ );
37
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { readWishlist } from '../../lib/wishlist/storage.js';
4
+ import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
5
+
6
+ export function WishlistIndicator() {
7
+ const [count, setCount] = useState(0);
8
+ const label = useChromeLabel('wishlist');
9
+
10
+ useEffect(() => {
11
+ const sync = () => setCount(readWishlist().length);
12
+ sync();
13
+ window.addEventListener('vitrine:wishlist', sync);
14
+ window.addEventListener('storage', sync);
15
+ return () => {
16
+ window.removeEventListener('vitrine:wishlist', sync);
17
+ window.removeEventListener('storage', sync);
18
+ };
19
+ }, []);
20
+
21
+ return (
22
+ <a
23
+ href="/wishlist"
24
+ className="vt-wishlist-indicator text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
25
+ >
26
+ {label}
27
+ {count > 0 ? ` (${count})` : ''}
28
+ </a>
29
+ );
30
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { readWishlist, writeWishlist } from '../../lib/wishlist/storage.js';
4
+
5
+ interface WishItem {
6
+ slug: string;
7
+ title?: string;
8
+ }
9
+
10
+ export function WishlistView() {
11
+ const [items, setItems] = useState<WishItem[]>([]);
12
+ const [loading, setLoading] = useState(true);
13
+
14
+ useEffect(() => {
15
+ const slugs = readWishlist();
16
+ if (slugs.length === 0) {
17
+ setItems([]);
18
+ setLoading(false);
19
+ return;
20
+ }
21
+ Promise.all(
22
+ slugs.map(async (slug) => {
23
+ try {
24
+ const res = await fetch(`/api/products?where[slug][equals]=${encodeURIComponent(slug)}&limit=1`);
25
+ if (!res.ok) return { slug };
26
+ const data = (await res.json()) as { docs?: Array<{ title?: string }> };
27
+ return { slug, title: data.docs?.[0]?.title };
28
+ } catch {
29
+ return { slug };
30
+ }
31
+ }),
32
+ ).then((list) => {
33
+ setItems(list);
34
+ setLoading(false);
35
+ });
36
+ }, []);
37
+
38
+ if (loading) return <p className="text-muted-fg">Loading wishlist…</p>;
39
+
40
+ if (items.length === 0) {
41
+ return (
42
+ <div className="flex flex-col gap-gutter">
43
+ <p className="text-muted-fg">Your wishlist is empty.</p>
44
+ <a href="/" className="w-fit text-fg underline underline-offset-2">
45
+ Browse catalog
46
+ </a>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <ul role="list" className="flex flex-col gap-gutter">
53
+ {items.map((item) => (
54
+ <li key={item.slug} className="flex items-center justify-between gap-gutter border-b border-border py-unit">
55
+ <a href={`/products/${item.slug}`} className="text-fg underline-offset-2 hover:underline">
56
+ {item.title ?? item.slug}
57
+ </a>
58
+ <button
59
+ type="button"
60
+ className="text-sm text-muted-fg underline underline-offset-2 hover:text-fg"
61
+ onClick={() => {
62
+ const next = readWishlist().filter((s) => s !== item.slug);
63
+ writeWishlist(next);
64
+ setItems((prev) => prev.filter((p) => p.slug !== item.slug));
65
+ }}
66
+ >
67
+ Remove
68
+ </button>
69
+ </li>
70
+ ))}
71
+ </ul>
72
+ );
73
+ }
@@ -0,0 +1,8 @@
1
+ import { registerSlot } from '@vitrine-kit/core';
2
+ import { WishlistButton } from '../../components/wishlist/WishlistButton.js';
3
+ import { WishlistIndicator } from '../../components/wishlist/WishlistIndicator.js';
4
+
5
+ export function registerWishlistSlots(): void {
6
+ registerSlot({ slot: 'product.below-price', component: WishlistButton, order: 20 });
7
+ registerSlot({ slot: 'global.header-actions', component: WishlistIndicator, order: 15 });
8
+ }
@@ -0,0 +1,30 @@
1
+ // localStorage wishlist of product slugs. Pure browser helper — no server.
2
+ const KEY = 'vitrine_wishlist';
3
+
4
+ export function readWishlist(): string[] {
5
+ if (typeof window === 'undefined') return [];
6
+ try {
7
+ const raw = window.localStorage.getItem(KEY);
8
+ if (!raw) return [];
9
+ const parsed = JSON.parse(raw) as unknown;
10
+ return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ export function writeWishlist(slugs: string[]): void {
17
+ window.localStorage.setItem(KEY, JSON.stringify([...new Set(slugs)]));
18
+ window.dispatchEvent(new Event('vitrine:wishlist'));
19
+ }
20
+
21
+ export function toggleWishlist(slug: string): string[] {
22
+ const current = readWishlist();
23
+ const next = current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug];
24
+ writeWishlist(next);
25
+ return next;
26
+ }
27
+
28
+ export function isWishlisted(slug: string): boolean {
29
+ return readWishlist().includes(slug);
30
+ }
@@ -25,5 +25,8 @@ WORKDIR /app
25
25
  COPY --from=build /app/public ./public
26
26
  COPY --from=build /app/.next/standalone ./
27
27
  COPY --from=build /app/.next/static ./.next/static
28
+ # Demo seed + uploads (SEED_ON_BOOT); media is also a named volume in compose.
29
+ COPY --from=build /app/seed-assets ./seed-assets
30
+ RUN mkdir -p /app/media
28
31
  EXPOSE 3000
29
32
  CMD ["node", "server.js"]
@@ -1,5 +1,6 @@
1
1
  # Reference Vitrine deployment on a VPS: app (Next+Payload) + Postgres.
2
2
  # Run: PAYLOAD_SECRET=... docker compose up --build
3
+ # First boot seeds demo catalog + admin when SEED_ON_BOOT=1 (default here).
3
4
  services:
4
5
  db:
5
6
  image: postgres:16-alpine
@@ -25,11 +26,18 @@ services:
25
26
  DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
26
27
  PAYLOAD_SECRET: ${PAYLOAD_SECRET:?set PAYLOAD_SECRET}
27
28
  NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
29
+ SEED_ON_BOOT: ${SEED_ON_BOOT:-1}
30
+ DEV_ADMIN_EMAIL: ${DEV_ADMIN_EMAIL:-admin@example.com}
31
+ DEV_ADMIN_PASSWORD: ${DEV_ADMIN_PASSWORD:-}
32
+ EMAIL_FROM: ${EMAIL_FROM:-noreply@localhost}
28
33
  ports:
29
34
  - '3000:3000'
35
+ volumes:
36
+ - media:/app/media
30
37
  depends_on:
31
38
  db:
32
39
  condition: service_healthy
33
40
 
34
41
  volumes:
35
42
  pgdata:
43
+ media:
@@ -9,19 +9,25 @@ import type {
9
9
  ProductSort,
10
10
  } from '@vitrine-kit/contracts';
11
11
  import { mapCategory, mapProduct } from './map.js';
12
+ import { applyProductQuery } from './product-query.js';
12
13
  import type { ProductDoc, VariantDoc } from './payload-types.js';
13
14
 
14
15
  function sortExpr(sort?: ProductSort): string {
16
+ // Price sorts are applied in memory after variants load (see applyProductQuery).
15
17
  switch (sort) {
16
18
  case 'price-asc':
17
- return 'createdAt'; // price lives in the variants collection — price sorting is provided by the filters feature
18
19
  case 'price-desc':
20
+ case 'relevance':
19
21
  return '-createdAt';
20
- default: // newest and unspecified sort
22
+ default:
21
23
  return '-createdAt';
22
24
  }
23
25
  }
24
26
 
27
+ function localeOpt(locale?: string): { locale?: string } {
28
+ return locale ? { locale } : {};
29
+ }
30
+
25
31
  export class PayloadCatalogSource implements CatalogSource {
26
32
  constructor(
27
33
  private readonly payload: Payload,
@@ -39,41 +45,69 @@ export class PayloadCatalogSource implements CatalogSource {
39
45
  }
40
46
 
41
47
  async listProducts(query: ProductQuery = {}): Promise<Product[]> {
42
- const where = query.category ? { 'categories.slug': { equals: query.category } } : {};
48
+ const where: Record<string, unknown> = {};
49
+ if (query.category) {
50
+ where['categories.slug'] = { equals: query.category };
51
+ }
52
+ // Prefer DB-side title match when searching; applyProductQuery also matches description.
53
+ if (query.search?.trim() && !query.filters && query.priceMin == null && query.priceMax == null) {
54
+ where.title = { like: query.search.trim() };
55
+ }
56
+
57
+ // When filtering by variant options/price or sorting by price, pull a wider page then refine.
58
+ const needsMemoryPass =
59
+ Boolean(query.filters && Object.keys(query.filters).length > 0) ||
60
+ query.priceMin != null ||
61
+ query.priceMax != null ||
62
+ query.sort === 'price-asc' ||
63
+ query.sort === 'price-desc' ||
64
+ query.sort === 'relevance';
65
+
66
+ const limit = needsMemoryPass ? Math.max(query.perPage ?? 24, 100) : (query.perPage ?? 24);
43
67
  const res = await this.payload.find({
44
68
  collection: 'products',
45
69
  where,
46
70
  depth: 1,
47
- limit: query.perPage ?? 24,
48
- page: query.page ?? 1,
71
+ limit,
72
+ page: needsMemoryPass ? 1 : (query.page ?? 1),
49
73
  sort: sortExpr(query.sort),
74
+ ...localeOpt(query.locale),
50
75
  });
51
- return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
76
+ let products = await Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
77
+ products = applyProductQuery(products, query);
78
+
79
+ if (needsMemoryPass) {
80
+ const page = query.page ?? 1;
81
+ const perPage = query.perPage ?? 24;
82
+ const start = (page - 1) * perPage;
83
+ return products.slice(start, start + perPage);
84
+ }
85
+ return products;
52
86
  }
53
87
 
54
- async getProduct(slug: string): Promise<Product | null> {
88
+ async getProduct(slug: string, locale?: string): Promise<Product | null> {
55
89
  const res = await this.payload.find({
56
90
  collection: 'products',
57
91
  where: { slug: { equals: slug } },
58
92
  depth: 1,
59
93
  limit: 1,
94
+ ...localeOpt(locale),
60
95
  });
61
96
  const doc = res.docs[0];
62
97
  return doc ? this.withVariants(doc as unknown as ProductDoc) : null;
63
98
  }
64
99
 
65
- async listCategories(): Promise<Category[]> {
66
- const res = await this.payload.find({ collection: 'categories', depth: 0, limit: 200 });
100
+ async listCategories(locale?: string): Promise<Category[]> {
101
+ const res = await this.payload.find({
102
+ collection: 'categories',
103
+ depth: 0,
104
+ limit: 200,
105
+ ...localeOpt(locale),
106
+ });
67
107
  return res.docs.map((d) => mapCategory(d as unknown as never));
68
108
  }
69
109
 
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)));
110
+ async search(term: string, locale?: string): Promise<Product[]> {
111
+ return this.listProducts({ search: term, sort: 'relevance', perPage: 24, locale });
78
112
  }
79
113
  }
@@ -72,6 +72,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
72
72
  id: variantId,
73
73
  depth: 1,
74
74
  })) as unknown as VariantDoc;
75
+ if (typeof variant.stock === 'number') {
76
+ if (variant.stock <= 0) throw new Error('[vitrine] variant is out of stock');
77
+ const alreadyInCart = cart.lines
78
+ .filter((l) => l.variantId === String(variant.id))
79
+ .reduce((sum, l) => sum + l.quantity, 0);
80
+ if (alreadyInCart + qty > variant.stock) {
81
+ throw new Error('[vitrine] not enough stock for this variant');
82
+ }
83
+ }
75
84
  const product = variant.product && typeof variant.product === 'object' ? (variant.product as ProductDoc) : null;
76
85
  const firstImage =
77
86
  product?.images && product.images.length > 0 && typeof product.images[0] === 'object'
@@ -120,6 +129,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
120
129
  const current = await this.getCart(cartId);
121
130
  if (!current || current.lines.length === 0) throw new Error('[vitrine] cart is empty');
122
131
  const cart = await this.reprice(current);
132
+ for (const line of cart.lines) {
133
+ const variant = (await this.payload
134
+ .findByID({ collection: 'variants', id: line.variantId })
135
+ .catch(() => null)) as unknown as VariantDoc | null;
136
+ if (!variant) throw new Error('[vitrine] a cart line refers to a missing variant');
137
+ if (typeof variant.stock === 'number' && line.quantity > variant.stock) {
138
+ throw new Error('[vitrine] not enough stock for this variant');
139
+ }
140
+ }
123
141
  // The active provider (Stripe/Paddle/YooKassa) is resolved by integrations.payments;
124
142
  // its checkout-<provider> feature registered it in the registry via lib/payments.ts.
125
143
  return payments.resolve(this.siteConfig).createCheckout({ cart, baseUrl: this.baseUrl });
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  // to the console once.
4
4
  import { randomBytes } from 'node:crypto';
5
5
  import type { Payload } from 'payload';
6
- import { shouldRunDevTask } from './guards.js';
6
+ import { seedOnBootEnabled, shouldRunDevTask } from './guards.js';
7
7
 
8
8
  function randomPassword(length = 16): string {
9
9
  return randomBytes(length).toString('base64url').slice(0, length);
@@ -12,12 +12,22 @@ function randomPassword(length = 16): string {
12
12
  export async function ensureDevAdmin(payload: Payload): Promise<void> {
13
13
  const isProd = process.env.NODE_ENV === 'production';
14
14
  const { totalDocs } = await payload.count({ collection: 'users' });
15
- if (!shouldRunDevTask({ isProd, existingCount: totalDocs })) return;
15
+ if (!shouldRunDevTask({ isProd, existingCount: totalDocs, seedOnBoot: seedOnBootEnabled() })) {
16
+ return;
17
+ }
16
18
 
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 } });
19
+ // `.env.example` ships `DEV_ADMIN_EMAIL=` / `DEV_ADMIN_PASSWORD=` — empty strings must
20
+ // not win over defaults (`??` only treats null/undefined).
21
+ const email = process.env.DEV_ADMIN_EMAIL?.trim() || 'admin@example.com';
22
+ const password = process.env.DEV_ADMIN_PASSWORD?.trim() || randomPassword();
23
+ await payload.create({
24
+ collection: 'users',
25
+ data: { email, password },
26
+ overrideAccess: true,
27
+ });
20
28
  payload.logger.warn(
21
- `[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
29
+ isProd
30
+ ? `[vitrine] BOOTSTRAP ADMIN ${email} / ${password} — change immediately (SEED_ON_BOOT)`
31
+ : `[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
22
32
  );
23
33
  }