@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.
- package/README.md +1 -1
- package/dist/index.js +114 -44
- 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 +101 -11
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
- 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 +21 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -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 +25 -4
- 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-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
- 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 +8 -4
- 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 +16 -6
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
- 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 +92 -16
- package/kit/templates/backend-payload/files/next.config.mjs +34 -0
- package/kit/templates/backend-payload/files/payload.config.ts +33 -3
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
- 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/backend-vendure/files/vendure-config.ts +38 -1
- package/kit/templates/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
- 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 +84 -6
- 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 +34 -0
- package/package.json +2 -2
|
@@ -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,10 +5,36 @@ import path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { DefaultJobQueuePlugin, DefaultSearchPlugin, type VendureConfig } from '@vendure/core';
|
|
7
7
|
import { AssetServerPlugin } from '@vendure/asset-server-plugin';
|
|
8
|
+
import { checkRateLimit } from '@vitrine-kit/core';
|
|
8
9
|
|
|
9
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const IS_DEV = process.env.NODE_ENV !== 'production';
|
|
11
12
|
|
|
13
|
+
// Structural Express req/res shape — avoids a direct dependency on `express` (it's only a
|
|
14
|
+
// transitive dep of @vendure/core); any real Express Request/Response satisfies this.
|
|
15
|
+
interface MiddlewareRequest {
|
|
16
|
+
headers: Record<string, string | string[] | undefined>;
|
|
17
|
+
ip?: string;
|
|
18
|
+
}
|
|
19
|
+
interface MiddlewareResponse {
|
|
20
|
+
setHeader(name: string, value: string): void;
|
|
21
|
+
status(code: number): { json(body: unknown): void };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Coarse per-IP rate limit on the whole admin API — brute-force protection for the
|
|
25
|
+
// superadmin login mutation (Vendure has no built-in login lockout, unlike Payload).
|
|
26
|
+
function adminApiRateLimit(req: MiddlewareRequest, res: MiddlewareResponse, next: () => void): void {
|
|
27
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
28
|
+
const ip = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0].trim()) ?? req.ip ?? 'unknown';
|
|
29
|
+
const { allowed, retryAfterMs } = checkRateLimit(`admin-api:${ip}`, { limit: 60, windowMs: 60_000 });
|
|
30
|
+
if (!allowed) {
|
|
31
|
+
res.setHeader('Retry-After', String(Math.ceil((retryAfterMs ?? 0) / 1000)));
|
|
32
|
+
res.status(429).json({ error: 'too many requests' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
next();
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
function dbConnectionOptions(): VendureConfig['dbConnectionOptions'] {
|
|
13
39
|
const url = process.env.DATABASE_URL;
|
|
14
40
|
if (url) {
|
|
@@ -26,17 +52,28 @@ function dbConnectionOptions(): VendureConfig['dbConnectionOptions'] {
|
|
|
26
52
|
} as VendureConfig['dbConnectionOptions'];
|
|
27
53
|
}
|
|
28
54
|
|
|
55
|
+
if (!IS_DEV) {
|
|
56
|
+
// No silent fallback to the well-known dev defaults in production (mirrors the DATABASE_URL guard below).
|
|
57
|
+
if (!process.env.VENDURE_SUPERADMIN_PASSWORD) {
|
|
58
|
+
throw new Error('[vitrine] VENDURE_SUPERADMIN_PASSWORD is required in production');
|
|
59
|
+
}
|
|
60
|
+
if (!process.env.VENDURE_COOKIE_SECRET) {
|
|
61
|
+
throw new Error('[vitrine] VENDURE_COOKIE_SECRET is required in production');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export const config: VendureConfig = {
|
|
30
66
|
apiOptions: {
|
|
31
67
|
port: Number(process.env.VENDURE_PORT ?? 3001),
|
|
32
68
|
adminApiPath: 'admin-api',
|
|
33
69
|
shopApiPath: 'shop-api',
|
|
70
|
+
middleware: [{ handler: adminApiRateLimit, route: 'admin-api' }],
|
|
34
71
|
},
|
|
35
72
|
authOptions: {
|
|
36
73
|
tokenMethod: ['bearer', 'cookie'],
|
|
37
74
|
superadminCredentials: {
|
|
38
75
|
identifier: process.env.VENDURE_SUPERADMIN_USERNAME ?? 'superadmin',
|
|
39
|
-
// dev default;
|
|
76
|
+
// dev default; production is guarded above.
|
|
40
77
|
password: process.env.VENDURE_SUPERADMIN_PASSWORD ?? 'superadmin',
|
|
41
78
|
},
|
|
42
79
|
cookieOptions: { secret: process.env.VENDURE_COOKIE_SECRET ?? 'dev-cookie-secret' },
|
|
@@ -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).
|
|
@@ -1,26 +1,78 @@
|
|
|
1
1
|
// Category listing. Filtered by category slug via the CatalogSource contract.
|
|
2
|
+
import type { Metadata } from 'next';
|
|
2
3
|
import { notFound } from 'next/navigation';
|
|
3
4
|
import { Slot } from '@vitrine-kit/core/react';
|
|
4
5
|
import { getCatalogSource } from '@/lib/adapter';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectOptionFacets,
|
|
8
|
+
loadCategories,
|
|
9
|
+
loadProducts,
|
|
10
|
+
parsePriceBound,
|
|
11
|
+
parseProductFilters,
|
|
12
|
+
parseProductSort,
|
|
13
|
+
} from '@/lib/catalog/data';
|
|
14
|
+
import { getRequestLocale } from '@/lib/i18n/locale';
|
|
6
15
|
import { ProductGrid } from '@/components/catalog/ProductGrid';
|
|
16
|
+
import { siteName } from '@/lib/site';
|
|
7
17
|
|
|
8
18
|
interface PageProps {
|
|
9
19
|
params: Promise<{ slug: string }>;
|
|
20
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
10
21
|
}
|
|
11
22
|
|
|
12
|
-
export
|
|
23
|
+
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
13
24
|
const { slug } = await params;
|
|
25
|
+
const locale = await getRequestLocale();
|
|
14
26
|
const source = await getCatalogSource();
|
|
15
|
-
const category = (await loadCategories(source)).find((c) => c.slug === slug);
|
|
27
|
+
const category = (await loadCategories(source, locale)).find((c) => c.slug === slug);
|
|
28
|
+
if (!category) return {};
|
|
29
|
+
return {
|
|
30
|
+
title: category.title,
|
|
31
|
+
description: category.description ?? `${category.title} — ${siteName}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function CategoryPage({ params, searchParams }: PageProps) {
|
|
36
|
+
const { slug } = await params;
|
|
37
|
+
const sp = await searchParams;
|
|
38
|
+
const sort = parseProductSort(typeof sp.sort === 'string' ? sp.sort : undefined) ?? 'newest';
|
|
39
|
+
const filters = parseProductFilters(sp);
|
|
40
|
+
const priceMin = parsePriceBound(sp.priceMin);
|
|
41
|
+
const priceMax = parsePriceBound(sp.priceMax);
|
|
42
|
+
const locale = await getRequestLocale();
|
|
43
|
+
const source = await getCatalogSource();
|
|
44
|
+
const category = (await loadCategories(source, locale)).find((c) => c.slug === slug);
|
|
16
45
|
if (!category) notFound();
|
|
17
46
|
|
|
18
|
-
const
|
|
47
|
+
const facetSource = await loadProducts(source, { category: slug, perPage: 100, locale });
|
|
48
|
+
const facets = collectOptionFacets(facetSource);
|
|
49
|
+
const products = await loadProducts(source, {
|
|
50
|
+
category: slug,
|
|
51
|
+
sort,
|
|
52
|
+
filters,
|
|
53
|
+
priceMin,
|
|
54
|
+
priceMax,
|
|
55
|
+
locale,
|
|
56
|
+
});
|
|
19
57
|
|
|
20
58
|
return (
|
|
21
59
|
<div className="flex flex-col gap-section">
|
|
22
60
|
<Slot name="category.header" category={category} />
|
|
23
|
-
<
|
|
61
|
+
<header className="flex flex-col gap-unit">
|
|
62
|
+
<h1 className="font-heading text-fg">{category.title}</h1>
|
|
63
|
+
{category.description ? (
|
|
64
|
+
<p className="max-w-prose text-muted-fg">{category.description}</p>
|
|
65
|
+
) : null}
|
|
66
|
+
</header>
|
|
67
|
+
<Slot
|
|
68
|
+
name="catalog.toolbar"
|
|
69
|
+
sort={sort}
|
|
70
|
+
action={`/categories/${slug}`}
|
|
71
|
+
facets={facets}
|
|
72
|
+
filters={filters ?? {}}
|
|
73
|
+
priceMin={typeof sp.priceMin === 'string' ? sp.priceMin : ''}
|
|
74
|
+
priceMax={typeof sp.priceMax === 'string' ? sp.priceMax : ''}
|
|
75
|
+
/>
|
|
24
76
|
<ProductGrid products={products} />
|
|
25
77
|
<Slot name="category.below-products" />
|
|
26
78
|
</div>
|
|
@@ -5,27 +5,33 @@ import type { ReactNode } from 'react';
|
|
|
5
5
|
import type { Metadata } from 'next';
|
|
6
6
|
import '../globals.css';
|
|
7
7
|
import { siteConfig } from '@/site.config';
|
|
8
|
+
import { baseUrl, siteName } from '@/lib/site';
|
|
8
9
|
import { registerSlots } from '@/lib/slots';
|
|
9
10
|
import { Slot } from '@vitrine-kit/core/react';
|
|
11
|
+
import { SlotsProvider } from '@/components/SlotsProvider';
|
|
10
12
|
import { SiteHeader } from '@/components/layout/SiteHeader';
|
|
11
13
|
import { SiteFooter } from '@/components/layout/SiteFooter';
|
|
12
14
|
|
|
13
|
-
// Register installed features' slots (lib/slots.ts is generated by the CLI).
|
|
15
|
+
// Register installed features' slots for SSR (lib/slots.ts is generated by the CLI).
|
|
16
|
+
// SlotsProvider re-runs the same registration in the browser so <Slot> hydrates.
|
|
14
17
|
registerSlots();
|
|
15
18
|
|
|
16
19
|
export const metadata: Metadata = {
|
|
17
|
-
|
|
20
|
+
metadataBase: new URL(baseUrl),
|
|
21
|
+
title: { default: siteName, template: `%s · ${siteName}` },
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
export default function FrontendLayout({ children }: { children: ReactNode }) {
|
|
21
25
|
return (
|
|
22
26
|
<html lang={siteConfig.i18n.defaultLocale}>
|
|
23
27
|
<body className="min-h-screen bg-bg font-sans text-fg antialiased">
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
<SlotsProvider>
|
|
29
|
+
<Slot name="global.banner-top" />
|
|
30
|
+
<SiteHeader />
|
|
31
|
+
<main className="mx-auto w-full max-w-container px-gutter py-section">{children}</main>
|
|
32
|
+
<SiteFooter />
|
|
33
|
+
<Slot name="global.body-end" />
|
|
34
|
+
</SlotsProvider>
|
|
29
35
|
</body>
|
|
30
36
|
</html>
|
|
31
37
|
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { siteName } from '@/lib/site';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Page not found',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function NotFound() {
|
|
9
|
+
return (
|
|
10
|
+
<div className="vt-not-found flex flex-col gap-section">
|
|
11
|
+
<header className="flex flex-col gap-unit">
|
|
12
|
+
<p className="text-sm text-muted-fg">404</p>
|
|
13
|
+
<h1 className="font-heading text-3xl text-fg">Page not found</h1>
|
|
14
|
+
<p className="max-w-prose text-muted-fg">
|
|
15
|
+
That page is missing or the product is no longer available in {siteName}.
|
|
16
|
+
</p>
|
|
17
|
+
</header>
|
|
18
|
+
<a
|
|
19
|
+
href="/"
|
|
20
|
+
className="w-fit rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
21
|
+
>
|
|
22
|
+
Back to catalog
|
|
23
|
+
</a>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,21 +1,99 @@
|
|
|
1
1
|
// Storefront home: catalog listing. Data via the CatalogSource contract
|
|
2
2
|
// (backend-agnostic); the grid is the catalog feature's component.
|
|
3
|
+
import type { Metadata } from 'next';
|
|
3
4
|
import { Slot } from '@vitrine-kit/core/react';
|
|
4
5
|
import { getCatalogSource } from '@/lib/adapter';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectOptionFacets,
|
|
8
|
+
loadProducts,
|
|
9
|
+
parsePriceBound,
|
|
10
|
+
parseProductFilters,
|
|
11
|
+
parseProductSort,
|
|
12
|
+
} from '@/lib/catalog/data';
|
|
13
|
+
import { getRequestLocale } from '@/lib/i18n/locale';
|
|
6
14
|
import { ProductGrid } from '@/components/catalog/ProductGrid';
|
|
15
|
+
import { siteName } from '@/lib/site';
|
|
7
16
|
|
|
8
|
-
|
|
17
|
+
function DemoHero() {
|
|
18
|
+
return (
|
|
19
|
+
<section
|
|
20
|
+
aria-labelledby="demo-hero-heading"
|
|
21
|
+
className="flex flex-col gap-gutter border-b border-border pb-section"
|
|
22
|
+
>
|
|
23
|
+
<p className="text-sm uppercase tracking-wide text-muted-fg">Demo catalog</p>
|
|
24
|
+
<h1 id="demo-hero-heading" className="font-heading text-3xl text-fg md:text-4xl">
|
|
25
|
+
{siteName}
|
|
26
|
+
</h1>
|
|
27
|
+
<p className="max-w-prose text-muted-fg">
|
|
28
|
+
Zero-config seed data is loaded for local development — five products across Apparel and
|
|
29
|
+
Accessories. Browse a category, open a product with size or color options, add it to the
|
|
30
|
+
cart, then continue to checkout when a payment provider is configured.
|
|
31
|
+
</p>
|
|
32
|
+
<div className="flex flex-wrap gap-gutter">
|
|
33
|
+
<a
|
|
34
|
+
href="/categories/apparel"
|
|
35
|
+
className="rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
36
|
+
>
|
|
37
|
+
Shop apparel
|
|
38
|
+
</a>
|
|
39
|
+
<a
|
|
40
|
+
href="/products/classic-tee"
|
|
41
|
+
className="rounded-md border border-border px-gutter py-unit text-fg transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
42
|
+
>
|
|
43
|
+
Try Classic T-Shirt
|
|
44
|
+
</a>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PageProps {
|
|
51
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
55
|
+
return {
|
|
56
|
+
title: { absolute: siteName },
|
|
57
|
+
description: `${siteName} — product catalog.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default async function HomePage({ searchParams }: PageProps) {
|
|
62
|
+
const params = await searchParams;
|
|
63
|
+
const sort = parseProductSort(typeof params.sort === 'string' ? params.sort : undefined) ?? 'newest';
|
|
64
|
+
const filters = parseProductFilters(params);
|
|
65
|
+
const priceMin = parsePriceBound(params.priceMin);
|
|
66
|
+
const priceMax = parsePriceBound(params.priceMax);
|
|
67
|
+
const locale = await getRequestLocale();
|
|
9
68
|
const source = await getCatalogSource();
|
|
10
|
-
|
|
69
|
+
|
|
70
|
+
const facetSource = await loadProducts(source, { perPage: 100, locale });
|
|
71
|
+
const facets = collectOptionFacets(facetSource);
|
|
72
|
+
const products = await loadProducts(source, {
|
|
73
|
+
perPage: 12,
|
|
74
|
+
sort,
|
|
75
|
+
filters,
|
|
76
|
+
priceMin,
|
|
77
|
+
priceMax,
|
|
78
|
+
locale,
|
|
79
|
+
});
|
|
11
80
|
|
|
12
81
|
return (
|
|
13
82
|
<div className="flex flex-col gap-section">
|
|
14
|
-
<Slot name="home.hero" />
|
|
83
|
+
<Slot name="home.hero" fallback={<DemoHero />} />
|
|
15
84
|
<section aria-labelledby="catalog-heading" className="flex flex-col gap-gutter">
|
|
16
|
-
<
|
|
85
|
+
<h2 id="catalog-heading" className="font-heading text-fg">
|
|
17
86
|
Catalog
|
|
18
|
-
</
|
|
87
|
+
</h2>
|
|
88
|
+
<Slot
|
|
89
|
+
name="catalog.toolbar"
|
|
90
|
+
sort={sort}
|
|
91
|
+
action="/"
|
|
92
|
+
facets={facets}
|
|
93
|
+
filters={filters ?? {}}
|
|
94
|
+
priceMin={typeof params.priceMin === 'string' ? params.priceMin : ''}
|
|
95
|
+
priceMax={typeof params.priceMax === 'string' ? params.priceMax : ''}
|
|
96
|
+
/>
|
|
19
97
|
<Slot name="catalog.grid-top" />
|
|
20
98
|
<ProductGrid products={products} />
|
|
21
99
|
<Slot name="catalog.grid-bottom" />
|
|
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
|
|
|
4
4
|
import { notFound } from 'next/navigation';
|
|
5
5
|
import { getCatalogSource } from '@/lib/adapter';
|
|
6
6
|
import { loadProduct } from '@/lib/catalog/data';
|
|
7
|
+
import { getRequestLocale } from '@/lib/i18n/locale';
|
|
7
8
|
import { ProductView } from '@/components/product/ProductView';
|
|
8
9
|
import { buildProductMetadata } from '@/lib/seo/metadata';
|
|
9
10
|
import { productJsonLd } from '@/lib/seo/jsonld';
|
|
@@ -16,8 +17,9 @@ interface PageProps {
|
|
|
16
17
|
|
|
17
18
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
18
19
|
const { slug } = await params;
|
|
20
|
+
const locale = await getRequestLocale();
|
|
19
21
|
const source = await getCatalogSource();
|
|
20
|
-
const product = await loadProduct(source, slug);
|
|
22
|
+
const product = await loadProduct(source, slug, locale);
|
|
21
23
|
if (!product) return {};
|
|
22
24
|
const meta = buildProductMetadata(product, { baseUrl });
|
|
23
25
|
return {
|
|
@@ -30,8 +32,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
30
32
|
|
|
31
33
|
export default async function ProductPage({ params }: PageProps) {
|
|
32
34
|
const { slug } = await params;
|
|
35
|
+
const locale = await getRequestLocale();
|
|
33
36
|
const source = await getCatalogSource();
|
|
34
|
-
const product = await loadProduct(source, slug);
|
|
37
|
+
const product = await loadProduct(source, slug, locale);
|
|
35
38
|
if (!product) notFound();
|
|
36
39
|
|
|
37
40
|
return (
|
|
@@ -4,3 +4,16 @@
|
|
|
4
4
|
@tailwind base;
|
|
5
5
|
@tailwind components;
|
|
6
6
|
@tailwind utilities;
|
|
7
|
+
|
|
8
|
+
/* Fallback if a design theme strips utilities — keep search label accessible. */
|
|
9
|
+
.sr-only {
|
|
10
|
+
position: absolute;
|
|
11
|
+
width: 1px;
|
|
12
|
+
height: 1px;
|
|
13
|
+
padding: 0;
|
|
14
|
+
margin: -1px;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
clip: rect(0, 0, 0, 0);
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
border-width: 0;
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ensures feature slot mounts are registered in the browser bundle.
|
|
2
|
+
// Server layout also calls registerSlots() for SSR; this covers hydration.
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import { registerSlots } from '@/lib/slots';
|
|
7
|
+
|
|
8
|
+
let registered = false;
|
|
9
|
+
|
|
10
|
+
function ensureRegistered(): void {
|
|
11
|
+
if (registered) return;
|
|
12
|
+
registerSlots();
|
|
13
|
+
registered = true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SlotsProvider({ children }: { children: ReactNode }) {
|
|
17
|
+
ensureRegistered();
|
|
18
|
+
return children;
|
|
19
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Storefront footer. Hosts the global.footer slot.
|
|
2
2
|
import { Slot } from '@vitrine-kit/core/react';
|
|
3
|
+
import { siteName } from '@/lib/site';
|
|
3
4
|
|
|
4
5
|
export function SiteFooter() {
|
|
5
6
|
return (
|
|
@@ -7,7 +8,11 @@ export function SiteFooter() {
|
|
|
7
8
|
<div className="mx-auto max-w-container px-gutter py-section text-muted-fg">
|
|
8
9
|
<Slot
|
|
9
10
|
name="global.footer"
|
|
10
|
-
fallback={
|
|
11
|
+
fallback={
|
|
12
|
+
<p>
|
|
13
|
+
© {new Date().getFullYear()} {siteName}
|
|
14
|
+
</p>
|
|
15
|
+
}
|
|
11
16
|
/>
|
|
12
17
|
</div>
|
|
13
18
|
</footer>
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// CategoryNav mounts) and passes it categories from the active CatalogSource.
|
|
3
3
|
import { Slot } from '@vitrine-kit/core/react';
|
|
4
4
|
import { getCatalogSource } from '@/lib/adapter';
|
|
5
|
+
import { siteName } from '@/lib/site';
|
|
6
|
+
import { siteConfig } from '@/site.config';
|
|
5
7
|
|
|
6
8
|
export async function SiteHeader() {
|
|
7
9
|
const source = await getCatalogSource();
|
|
@@ -11,11 +13,15 @@ export async function SiteHeader() {
|
|
|
11
13
|
<header className="border-b border-border">
|
|
12
14
|
<div className="mx-auto flex max-w-container items-center justify-between gap-gutter px-gutter py-unit">
|
|
13
15
|
<a href="/" className="font-heading text-fg">
|
|
14
|
-
<Slot name="global.header-start" fallback={<span>
|
|
16
|
+
<Slot name="global.header-start" fallback={<span>{siteName}</span>} />
|
|
15
17
|
</a>
|
|
16
18
|
<Slot name="global.header-nav" categories={categories} />
|
|
17
19
|
<div className="flex items-center gap-unit">
|
|
18
|
-
<Slot
|
|
20
|
+
<Slot
|
|
21
|
+
name="global.header-actions"
|
|
22
|
+
locales={siteConfig.i18n.locales}
|
|
23
|
+
defaultLocale={siteConfig.i18n.defaultLocale}
|
|
24
|
+
/>
|
|
19
25
|
</div>
|
|
20
26
|
</div>
|
|
21
27
|
</header>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Minimal chrome dictionary (English). The `i18n` feature overwrites this file
|
|
2
|
+
// with multi-locale dictionaries when installed.
|
|
3
|
+
export type MessageKey =
|
|
4
|
+
| 'cart'
|
|
5
|
+
| 'search'
|
|
6
|
+
| 'checkout'
|
|
7
|
+
| 'wishlist'
|
|
8
|
+
| 'orders'
|
|
9
|
+
| 'account'
|
|
10
|
+
| 'continueShopping'
|
|
11
|
+
| 'addToCart'
|
|
12
|
+
| 'thankYou'
|
|
13
|
+
| 'signIn'
|
|
14
|
+
| 'createAccount';
|
|
15
|
+
|
|
16
|
+
const en: Record<MessageKey, string> = {
|
|
17
|
+
cart: 'Cart',
|
|
18
|
+
search: 'Search',
|
|
19
|
+
checkout: 'Checkout',
|
|
20
|
+
wishlist: 'Wishlist',
|
|
21
|
+
orders: 'Orders',
|
|
22
|
+
account: 'Account',
|
|
23
|
+
continueShopping: 'Continue shopping',
|
|
24
|
+
addToCart: 'Add to cart',
|
|
25
|
+
thankYou: 'Thank you for your order',
|
|
26
|
+
signIn: 'Sign in',
|
|
27
|
+
createAccount: 'Create account',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function t(key: MessageKey, _locale = 'en'): string {
|
|
31
|
+
return en[key] ?? key;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function supportedLocales(): string[] {
|
|
35
|
+
return ['en'];
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Request locale from the vitrine_locale cookie (set by i18n middleware / switcher).
|
|
2
|
+
import { cookies } from 'next/headers';
|
|
3
|
+
import { siteConfig } from '@/site.config';
|
|
4
|
+
|
|
5
|
+
export async function getRequestLocale(): Promise<string> {
|
|
6
|
+
const jar = await cookies();
|
|
7
|
+
const fromCookie = jar.get('vitrine_locale')?.value;
|
|
8
|
+
const locales = siteConfig.i18n.locales;
|
|
9
|
+
if (fromCookie && locales.includes(fromCookie)) return fromCookie;
|
|
10
|
+
return siteConfig.i18n.defaultLocale;
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Client helper: resolve chrome copy from the locale cookie + dictionary.
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { t, type MessageKey } from './dictionary.js';
|
|
6
|
+
|
|
7
|
+
const COOKIE = 'vitrine_locale';
|
|
8
|
+
|
|
9
|
+
function readLocale(fallback: string): string {
|
|
10
|
+
if (typeof document === 'undefined') return fallback;
|
|
11
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]*)`));
|
|
12
|
+
return match ? decodeURIComponent(match[1]!) : fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Reactive label for header/chrome controls (updates after mount from the locale cookie). */
|
|
16
|
+
export function useChromeLabel(key: MessageKey, fallbackLocale = 'en'): string {
|
|
17
|
+
const [label, setLabel] = useState(() => t(key, fallbackLocale));
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setLabel(t(key, readLocale(fallbackLocale)));
|
|
21
|
+
}, [key, fallbackLocale]);
|
|
22
|
+
|
|
23
|
+
return label;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function chromeLabel(key: MessageKey, locale = 'en'): string {
|
|
27
|
+
return t(key, locale);
|
|
28
|
+
}
|
|
@@ -5,6 +5,9 @@ import { siteConfig } from '@/site.config';
|
|
|
5
5
|
/** Base URL for canonical/OG. Overridden by NEXT_PUBLIC_SITE_URL. */
|
|
6
6
|
export const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
|
7
7
|
|
|
8
|
+
/** Storefront display name from `vitrine init` (falls back for older clients). */
|
|
9
|
+
export const siteName = siteConfig.name?.trim() || 'Store';
|
|
10
|
+
|
|
8
11
|
export const currency = siteConfig.i18n.currency;
|
|
9
12
|
export const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
10
13
|
export const locales = siteConfig.i18n.locales;
|
|
@@ -1,6 +1,40 @@
|
|
|
1
|
+
// Baseline security headers (OWASP A05: Security Misconfiguration) applied to every route.
|
|
2
|
+
// The CSP below is a conservative starting point — payment providers with client-side widgets
|
|
3
|
+
// (e.g. embedded Stripe Elements) may need their domains added to script-src/connect-src/frame-src.
|
|
4
|
+
// Next.js `pnpm dev` (webpack eval source maps / Fast Refresh) needs 'unsafe-eval'; omit it in
|
|
5
|
+
// production builds so the shipped CSP stays tighter.
|
|
6
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
7
|
+
const scriptSrc = isDev
|
|
8
|
+
? "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
|
|
9
|
+
: "script-src 'self' 'unsafe-inline'";
|
|
10
|
+
|
|
11
|
+
const securityHeaders = [
|
|
12
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
13
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
14
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
15
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
16
|
+
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
|
17
|
+
{
|
|
18
|
+
key: 'Content-Security-Policy',
|
|
19
|
+
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';`,
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
1
23
|
/** @type {import('next').NextConfig} */
|
|
2
24
|
const nextConfig = {
|
|
3
25
|
output: 'standalone',
|
|
26
|
+
// Registry/template sources use TypeScript ESM-style relative imports (`.js` → `.ts`/`.tsx`).
|
|
27
|
+
// Webpack needs an explicit extensionAlias; without it, `pnpm dev` fails to resolve components.
|
|
28
|
+
webpack(config) {
|
|
29
|
+
config.resolve.extensionAlias = {
|
|
30
|
+
...(config.resolve.extensionAlias ?? {}),
|
|
31
|
+
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
|
32
|
+
};
|
|
33
|
+
return config;
|
|
34
|
+
},
|
|
35
|
+
async headers() {
|
|
36
|
+
return [{ source: '/:path*', headers: securityHeaders }];
|
|
37
|
+
},
|
|
4
38
|
};
|
|
5
39
|
|
|
6
40
|
// backend-payload overwrites this file (wraps it in withPayload).
|