@vitrine-kit/vitrine 0.4.3 → 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.
- package/README.md +1 -1
- package/dist/index.js +114 -45
- package/kit/registry/_index.json +70 -9
- package/kit/registry/accounts/docs/accounts.md +20 -0
- package/kit/registry/accounts/feature.json +55 -0
- package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
- package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
- package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
- package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
- package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
- package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
- package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
- package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
- package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
- package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
- package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
- package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
- package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
- package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/cart/files/app/api/cart/route.ts +46 -8
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +38 -3
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
- package/kit/registry/cart/files/components/cart/CartView.tsx +9 -7
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
- package/kit/registry/checkout/docs/checkout.md +19 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -3
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
- package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
- package/kit/registry/checkout-paddle/feature.json +1 -1
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/email/docs/email.md +19 -0
- package/kit/registry/email/feature.json +60 -0
- package/kit/registry/email/files/lib/email/adapter.ts +89 -0
- package/kit/registry/filters/docs/filters.md +13 -0
- package/kit/registry/filters/feature.json +45 -0
- package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
- package/kit/registry/filters/files/lib/filters/register.ts +6 -0
- package/kit/registry/i18n/docs/i18n.md +24 -0
- package/kit/registry/i18n/feature.json +47 -0
- package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
- package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
- package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
- package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
- package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
- package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/registry/i18n/files/middleware.ts +51 -0
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
- package/kit/registry/product-page/files/components/product/ProductView.tsx +3 -3
- package/kit/registry/reviews/docs/reviews.md +11 -0
- package/kit/registry/reviews/feature.json +55 -0
- package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
- package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
- package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
- package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
- package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
- package/kit/registry/search/docs/search.md +12 -0
- package/kit/registry/search/feature.json +49 -0
- package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
- package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
- package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
- package/kit/registry/search/files/lib/search/data.ts +8 -0
- package/kit/registry/search/files/lib/search/register.ts +8 -0
- package/kit/registry/seo/docs/seo.md +4 -1
- package/kit/registry/seo/feature.json +5 -1
- package/kit/registry/seo/files/app/robots.ts +13 -0
- package/kit/registry/seo/files/app/sitemap.ts +27 -0
- package/kit/registry/stubs/route-glue.d.ts +71 -0
- package/kit/registry/tsconfig.json +7 -2
- package/kit/registry/wishlist/docs/wishlist.md +11 -0
- package/kit/registry/wishlist/feature.json +55 -0
- package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
- package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
- package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
- package/kit/templates/backend-payload/files/Dockerfile +3 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
- package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +7 -3
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
- package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +57 -6
- package/kit/templates/backend-payload/files/next.config.mjs +8 -2
- package/kit/templates/backend-payload/files/payload.config.ts +27 -3
- package/kit/templates/backend-vendure/files/Dockerfile +1 -1
- package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
- package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
- package/kit/templates/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +51 -4
- package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
- package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +49 -4
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
- package/kit/templates/base/files/app/globals.css +13 -0
- package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
- package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
- package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
- package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/templates/base/files/lib/site.ts +3 -0
- package/kit/templates/base/files/next.config.mjs +8 -2
- package/package.json +2 -2
|
@@ -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,7 +12,9 @@ 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 }))
|
|
15
|
+
if (!shouldRunDevTask({ isProd, existingCount: totalDocs, seedOnBoot: seedOnBootEnabled() })) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
16
18
|
|
|
17
19
|
// `.env.example` ships `DEV_ADMIN_EMAIL=` / `DEV_ADMIN_PASSWORD=` — empty strings must
|
|
18
20
|
// not win over defaults (`??` only treats null/undefined).
|
|
@@ -24,6 +26,8 @@ export async function ensureDevAdmin(payload: Payload): Promise<void> {
|
|
|
24
26
|
overrideAccess: true,
|
|
25
27
|
});
|
|
26
28
|
payload.logger.warn(
|
|
27
|
-
|
|
29
|
+
isProd
|
|
30
|
+
? `[vitrine] BOOTSTRAP ADMIN ${email} / ${password} — change immediately (SEED_ON_BOOT)`
|
|
31
|
+
: `[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
|
|
28
32
|
);
|
|
29
33
|
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
// Pure guards for zero-config
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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,28 +1,61 @@
|
|
|
1
|
-
// Demo seed (§18.2): runs from Payload's onInit. Idempotent (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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 }))
|
|
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
39
|
const doc = await payload.create({
|
|
21
40
|
collection: 'categories',
|
|
22
41
|
data: { slug: c.slug, title: c.title, description: c.description },
|
|
42
|
+
...createOpts,
|
|
23
43
|
overrideAccess: true,
|
|
24
44
|
});
|
|
25
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
|
+
}
|
|
26
59
|
}
|
|
27
60
|
|
|
28
61
|
for (const p of demoProducts) {
|
|
@@ -52,8 +85,26 @@ export async function seedDemo(payload: Payload): Promise<void> {
|
|
|
52
85
|
image: coverId,
|
|
53
86
|
},
|
|
54
87
|
},
|
|
88
|
+
...createOpts,
|
|
55
89
|
overrideAccess: true,
|
|
56
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
|
+
}
|
|
57
108
|
for (const v of p.variants) {
|
|
58
109
|
await payload.create({
|
|
59
110
|
collection: 'variants',
|
|
@@ -5,6 +5,13 @@ import { withPayload } from '@payloadcms/next/withPayload';
|
|
|
5
5
|
// Baseline security headers (OWASP A05: Security Misconfiguration) applied to every route.
|
|
6
6
|
// The CSP below is a conservative starting point — payment providers with client-side widgets
|
|
7
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
|
+
|
|
8
15
|
const securityHeaders = [
|
|
9
16
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
10
17
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
@@ -13,8 +20,7 @@ const securityHeaders = [
|
|
|
13
20
|
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
|
14
21
|
{
|
|
15
22
|
key: 'Content-Security-Policy',
|
|
16
|
-
value:
|
|
17
|
-
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';",
|
|
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';`,
|
|
18
24
|
},
|
|
19
25
|
];
|
|
20
26
|
|
|
@@ -1,25 +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
|
|
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
|
|
|
17
|
-
|
|
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;
|
|
18
22
|
if (!payloadSecret && process.env.NODE_ENV === 'production') {
|
|
19
23
|
// No silent fallback to the dev secret in production (mirrors the Vendure DATABASE_URL guard).
|
|
20
24
|
throw new Error('[vitrine] PAYLOAD_SECRET is required in production');
|
|
21
25
|
}
|
|
22
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
|
+
|
|
23
45
|
export default buildConfig({
|
|
24
46
|
admin: {
|
|
25
47
|
user: 'users',
|
|
@@ -30,6 +52,8 @@ export default buildConfig({
|
|
|
30
52
|
secret: payloadSecret ?? 'dev-secret-change-me',
|
|
31
53
|
db: await resolveDbAdapter(),
|
|
32
54
|
sharp,
|
|
55
|
+
localization,
|
|
56
|
+
...(email ? { email: email as never } : {}),
|
|
33
57
|
typescript: { outputFile: path.resolve(dirname, 'payload-types.ts') },
|
|
34
58
|
async onInit(payload) {
|
|
35
59
|
await seedDemo(payload);
|
|
@@ -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
|
-
# (`
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { CatalogSource, Category, Product, ProductQuery } from '@vitrine-kit/contracts';
|
|
5
5
|
import { shopQuery } from './graphql.js';
|
|
6
6
|
import { mapVendureCollection, mapVendureProduct } from './map.js';
|
|
7
|
+
import { applyProductQuery } from './product-query.js';
|
|
7
8
|
import type { VCollection, VProduct } from './vendure-types.js';
|
|
8
9
|
|
|
9
10
|
const PRODUCT_FIELDS = `
|
|
@@ -19,24 +20,30 @@ export class VendureCatalogSource implements CatalogSource {
|
|
|
19
20
|
const take = query.perPage ?? 24;
|
|
20
21
|
const skip = ((query.page ?? 1) - 1) * take;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
let products: Product[];
|
|
24
|
+
if (query.search?.trim()) {
|
|
25
|
+
products = await this.search(query.search.trim(), query.locale);
|
|
26
|
+
} else if (query.category) {
|
|
23
27
|
const { data } = await shopQuery<{ collection: { productVariants: { items: { product: VProduct }[] } } | null }>(
|
|
24
28
|
`query ($slug: String!) { collection(slug: $slug) { productVariants { items { product { ${PRODUCT_FIELDS} } } } } }`,
|
|
25
29
|
{ slug: query.category },
|
|
26
30
|
);
|
|
27
31
|
const byId = new Map<string, VProduct>();
|
|
28
32
|
for (const it of data.collection?.productVariants.items ?? []) byId.set(String(it.product.id), it.product);
|
|
29
|
-
|
|
33
|
+
products = [...byId.values()].map(mapVendureProduct);
|
|
34
|
+
} else {
|
|
35
|
+
const { data } = await shopQuery<{ products: { items: VProduct[] } }>(
|
|
36
|
+
`query ($take: Int, $skip: Int) { products(options: { take: $take, skip: $skip }) { items { ${PRODUCT_FIELDS} } } }`,
|
|
37
|
+
{ take: Math.max(take, 100), skip: 0 },
|
|
38
|
+
);
|
|
39
|
+
products = data.products.items.map(mapVendureProduct);
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{ take, skip },
|
|
35
|
-
);
|
|
36
|
-
return data.products.items.map(mapVendureProduct);
|
|
42
|
+
products = applyProductQuery(products, query);
|
|
43
|
+
return products.slice(skip, skip + take);
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
async getProduct(slug: string): Promise<Product | null> {
|
|
46
|
+
async getProduct(slug: string, _locale?: string): Promise<Product | null> {
|
|
40
47
|
const { data } = await shopQuery<{ product: VProduct | null }>(
|
|
41
48
|
`query ($slug: String!) { product(slug: $slug) { ${PRODUCT_FIELDS} } }`,
|
|
42
49
|
{ slug },
|
|
@@ -44,19 +51,19 @@ export class VendureCatalogSource implements CatalogSource {
|
|
|
44
51
|
return data.product ? mapVendureProduct(data.product) : null;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
async listCategories(): Promise<Category[]> {
|
|
54
|
+
async listCategories(_locale?: string): Promise<Category[]> {
|
|
48
55
|
const { data } = await shopQuery<{ collections: { items: VCollection[] } }>(
|
|
49
56
|
`{ collections { items { id slug name description parent { id } } } }`,
|
|
50
57
|
);
|
|
51
58
|
return data.collections.items.map(mapVendureCollection);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
async search(term: string): Promise<Product[]> {
|
|
61
|
+
async search(term: string, locale?: string): Promise<Product[]> {
|
|
55
62
|
const { data } = await shopQuery<{ search: { items: { slug: string }[] } }>(
|
|
56
63
|
`query ($term: String!) { search(input: { term: $term, groupByProduct: true }) { items { slug } } }`,
|
|
57
64
|
{ term },
|
|
58
65
|
);
|
|
59
|
-
const products = await Promise.all(data.search.items.map((i) => this.getProduct(i.slug)));
|
|
66
|
+
const products = await Promise.all(data.search.items.map((i) => this.getProduct(i.slug, locale)));
|
|
60
67
|
return products.filter((p): p is Product => p !== null);
|
|
61
68
|
}
|
|
62
69
|
}
|
|
@@ -5,7 +5,7 @@ argument-hint: [feature]
|
|
|
5
5
|
|
|
6
6
|
Update features from the registry while keeping the client's edits:
|
|
7
7
|
|
|
8
|
-
1. `vitrine kit update` — refresh the local registry/templates cache from
|
|
8
|
+
1. `vitrine kit update` — refresh the local registry/templates cache from npm
|
|
9
9
|
(needs `gh` and `tar`; cache in `~/.vitrine`).
|
|
10
10
|
2. `vitrine kit status` — see what's newer than what's installed.
|
|
11
11
|
3. `vitrine diff $ARGUMENTS` — preview changes without writing (for each feature being updated).
|