@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.
Files changed (117) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +114 -45
  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 +46 -8
  24. package/kit/registry/cart/files/components/cart/AddToCart.tsx +38 -3
  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 +9 -7
  28. package/kit/registry/catalog/feature.json +1 -1
  29. package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
  30. package/kit/registry/checkout/docs/checkout.md +19 -1
  31. package/kit/registry/checkout/feature.json +1 -1
  32. package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
  33. package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -3
  34. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
  35. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
  36. package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
  37. package/kit/registry/checkout-paddle/feature.json +1 -1
  38. package/kit/registry/checkout-stripe/feature.json +1 -1
  39. package/kit/registry/checkout-yookassa/feature.json +1 -1
  40. package/kit/registry/email/docs/email.md +19 -0
  41. package/kit/registry/email/feature.json +60 -0
  42. package/kit/registry/email/files/lib/email/adapter.ts +89 -0
  43. package/kit/registry/filters/docs/filters.md +13 -0
  44. package/kit/registry/filters/feature.json +45 -0
  45. package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
  46. package/kit/registry/filters/files/lib/filters/register.ts +6 -0
  47. package/kit/registry/i18n/docs/i18n.md +24 -0
  48. package/kit/registry/i18n/feature.json +47 -0
  49. package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
  50. package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
  51. package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
  52. package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
  53. package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
  54. package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
  55. package/kit/registry/i18n/files/middleware.ts +51 -0
  56. package/kit/registry/product-page/feature.json +1 -1
  57. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
  58. package/kit/registry/product-page/files/components/product/ProductView.tsx +3 -3
  59. package/kit/registry/reviews/docs/reviews.md +11 -0
  60. package/kit/registry/reviews/feature.json +55 -0
  61. package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
  62. package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
  63. package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
  64. package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
  65. package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
  66. package/kit/registry/search/docs/search.md +12 -0
  67. package/kit/registry/search/feature.json +49 -0
  68. package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
  69. package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
  70. package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
  71. package/kit/registry/search/files/lib/search/data.ts +8 -0
  72. package/kit/registry/search/files/lib/search/register.ts +8 -0
  73. package/kit/registry/seo/docs/seo.md +4 -1
  74. package/kit/registry/seo/feature.json +5 -1
  75. package/kit/registry/seo/files/app/robots.ts +13 -0
  76. package/kit/registry/seo/files/app/sitemap.ts +27 -0
  77. package/kit/registry/stubs/route-glue.d.ts +71 -0
  78. package/kit/registry/tsconfig.json +7 -2
  79. package/kit/registry/wishlist/docs/wishlist.md +11 -0
  80. package/kit/registry/wishlist/feature.json +55 -0
  81. package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
  82. package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
  83. package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
  84. package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
  85. package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
  86. package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
  87. package/kit/templates/backend-payload/files/Dockerfile +3 -0
  88. package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
  89. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
  90. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
  91. package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
  92. package/kit/templates/backend-payload/files/lib/seed/admin.ts +7 -3
  93. package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
  94. package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
  95. package/kit/templates/backend-payload/files/lib/seed/run.ts +57 -6
  96. package/kit/templates/backend-payload/files/next.config.mjs +8 -2
  97. package/kit/templates/backend-payload/files/payload.config.ts +27 -3
  98. package/kit/templates/backend-vendure/files/Dockerfile +1 -1
  99. package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
  100. package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
  101. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
  102. package/kit/templates/base/files/.claude/commands/update.md +1 -1
  103. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +51 -4
  104. package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
  105. package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
  106. package/kit/templates/base/files/app/(frontend)/page.tsx +49 -4
  107. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
  108. package/kit/templates/base/files/app/globals.css +13 -0
  109. package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
  110. package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
  111. package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
  112. package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
  113. package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
  114. package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
  115. package/kit/templates/base/files/lib/site.ts +3 -0
  116. package/kit/templates/base/files/next.config.mjs +8 -2
  117. package/package.json +2 -2
@@ -0,0 +1,52 @@
1
+ // Order confirmation email. Uses Payload sendEmail when the email feature + SMTP
2
+ // are configured; otherwise logs to the console (zero-config).
3
+ import type { Order } from '@vitrine-kit/contracts';
4
+ import { siteConfig } from '@/site.config';
5
+ import { siteName } from '@/lib/site';
6
+
7
+ export async function notifyOrderConfirmation(order: Order): Promise<void> {
8
+ const to = order.email?.trim();
9
+ if (!to) return;
10
+
11
+ const provider = siteConfig.integrations.email;
12
+ const subject = `Order confirmation — ${siteName}`;
13
+ const body = [
14
+ `Thanks for your order at ${siteName}.`,
15
+ ``,
16
+ `Order total: ${(order.total / 100).toFixed(2)} ${order.currency}`,
17
+ `Status: ${order.status}`,
18
+ `Lines: ${order.lines.map((l) => `${l.quantity}× ${l.title}`).join(', ')}`,
19
+ ].join('\n');
20
+
21
+ let from: string | undefined;
22
+ try {
23
+ const emailMod = await import('../email/adapter.js');
24
+ from = emailMod.emailFromAddress();
25
+ } catch {
26
+ // email feature not installed
27
+ }
28
+
29
+ if (provider || from) {
30
+ try {
31
+ const { getPayload } = await import('payload');
32
+ const config = (await import('@payload-config')).default;
33
+ const payload = await getPayload({ config });
34
+ await payload.sendEmail({
35
+ to,
36
+ from: from ?? undefined,
37
+ subject,
38
+ text: body,
39
+ });
40
+ return;
41
+ } catch (error) {
42
+ console.warn(
43
+ '[vitrine] payload.sendEmail failed — falling back to console log',
44
+ error instanceof Error ? error.message : error,
45
+ );
46
+ }
47
+ }
48
+
49
+ console.info(
50
+ `[vitrine] order email${provider ? ` via "${provider}"` : ''}${from ? ` from ${from}` : ''} → ${to}\n${subject}\n${body}`,
51
+ );
52
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-paddle",
4
4
  "title": "Payment — Paddle",
5
- "kitVersion": "0.4.3",
5
+ "kitVersion": "0.5.0",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-stripe",
4
4
  "title": "Payment — Stripe",
5
- "kitVersion": "0.4.3",
5
+ "kitVersion": "0.5.0",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../schemas/feature.schema.json",
3
3
  "name": "checkout-yookassa",
4
4
  "title": "Payment — YooKassa",
5
- "kitVersion": "0.4.3",
5
+ "kitVersion": "0.5.0",
6
6
  "requiresContracts": ">=1.0.0 <2.0.0",
7
7
  "tier": [
8
8
  "simple-store",
@@ -0,0 +1,19 @@
1
+ # Feature: email
2
+
3
+ Wires Payload's email transport for order confirmations and auth mail.
4
+
5
+ ## Modules
6
+ - `lib/email/adapter.ts` — `resolveEmailAdapter()` builds `@payloadcms/email-nodemailer`
7
+ from `SMTP_*` / `EMAIL_FROM`, or a **console adapter** when `EMAIL_FROM` is set without
8
+ `SMTP_HOST` (reset links and order mail print to the terminal / `docker compose logs`)
9
+ - `payload.config` (template) awaits the adapter when the file is present
10
+ - Checkout `notifyOrderConfirmation` calls `payload.sendEmail` when an adapter is configured
11
+
12
+ ## Setup
13
+ 1. `vitrine add email` (installs `@payloadcms/email-nodemailer` + `nodemailer`)
14
+ 2. Set `EMAIL_FROM` (required for any adapter). Optionally `SMTP_HOST` / `SMTP_PORT` /
15
+ `SMTP_USER` / `SMTP_PASS` / `EMAIL_FROM_NAME` for real delivery
16
+ 3. `integrations.email` is set to `"smtp"` by the feature config
17
+
18
+ Without `SMTP_HOST`, zero-config still works — messages (including password-reset links)
19
+ are logged to the console via the console adapter.
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "email",
4
+ "title": "Transactional email",
5
+ "kitVersion": "0.5.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "simple-store",
9
+ "full-store"
10
+ ],
11
+ "registryDependencies": [
12
+ "checkout"
13
+ ],
14
+ "corePackages": {},
15
+ "npm": [
16
+ "@payloadcms/email-nodemailer@^3.0.0",
17
+ "nodemailer@^6.9.0"
18
+ ],
19
+ "files": [
20
+ {
21
+ "from": "files/lib/email/",
22
+ "to": "lib/email/"
23
+ }
24
+ ],
25
+ "config": {
26
+ "set": {
27
+ "features.email": true
28
+ }
29
+ },
30
+ "slots": [],
31
+ "env": [
32
+ {
33
+ "key": "EMAIL_FROM",
34
+ "required": false
35
+ },
36
+ {
37
+ "key": "EMAIL_FROM_NAME",
38
+ "required": false
39
+ },
40
+ {
41
+ "key": "SMTP_HOST",
42
+ "required": false
43
+ },
44
+ {
45
+ "key": "SMTP_PORT",
46
+ "required": false
47
+ },
48
+ {
49
+ "key": "SMTP_USER",
50
+ "required": false
51
+ },
52
+ {
53
+ "key": "SMTP_PASS",
54
+ "required": false
55
+ }
56
+ ],
57
+ "claudeDoc": "docs/email.md",
58
+ "conflicts": [],
59
+ "removable": true
60
+ }
@@ -0,0 +1,89 @@
1
+ // Avoid importing `payload` types here — registry typecheck has no Payload deps.
2
+ export type VitrineEmailAdapter = {
3
+ name: string;
4
+ defaultFromAddress: string;
5
+ defaultFromName: string;
6
+ sendEmail: (message: unknown) => Promise<unknown>;
7
+ };
8
+
9
+ type EmailMessage = {
10
+ to?: string | string[];
11
+ subject?: string;
12
+ html?: string;
13
+ text?: string;
14
+ };
15
+
16
+ export function emailFromAddress(): string | undefined {
17
+ return process.env.EMAIL_FROM?.trim() || undefined;
18
+ }
19
+
20
+ export function emailFromName(): string {
21
+ return process.env.EMAIL_FROM_NAME?.trim() || 'Store';
22
+ }
23
+
24
+ /** Dev/zero-config adapter: logs the full message (incl. reset links) to the console. */
25
+ function consoleEmailAdapter(from: string): VitrineEmailAdapter {
26
+ return {
27
+ name: 'vitrine-console',
28
+ defaultFromAddress: from,
29
+ defaultFromName: emailFromName(),
30
+ async sendEmail(message: unknown) {
31
+ const msg = (message ?? {}) as EmailMessage;
32
+ const to = Array.isArray(msg.to) ? msg.to.join(', ') : (msg.to ?? '(unknown)');
33
+ const body = msg.text || msg.html || '';
34
+ console.info(
35
+ [
36
+ '[vitrine] console email (no SMTP_HOST)',
37
+ ` from: ${from}`,
38
+ ` to: ${to}`,
39
+ ` subject: ${msg.subject ?? '(none)'}`,
40
+ ' ---',
41
+ body,
42
+ ' ---',
43
+ ].join('\n'),
44
+ );
45
+ return { messageId: `console-${Date.now()}` };
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Resolve Payload's `email` config entry. Dynamic-imports `@payloadcms/email-nodemailer`
52
+ * when SMTP is configured; otherwise returns a console adapter so auth reset links
53
+ * and order mail still surface in docker logs / the terminal.
54
+ */
55
+ export async function resolveEmailAdapter(): Promise<VitrineEmailAdapter | undefined> {
56
+ const from = emailFromAddress();
57
+ const host = process.env.SMTP_HOST?.trim();
58
+ if (!from) return undefined;
59
+
60
+ if (!host) {
61
+ console.info(`[vitrine] EMAIL_FROM=${from} (no SMTP_HOST — console email adapter)`);
62
+ return consoleEmailAdapter(from);
63
+ }
64
+
65
+ try {
66
+ const { nodemailerAdapter } = await import('@payloadcms/email-nodemailer');
67
+ const port = Number(process.env.SMTP_PORT || 587);
68
+ const user = process.env.SMTP_USER?.trim();
69
+ const pass = process.env.SMTP_PASS ?? '';
70
+ console.info(`[vitrine] SMTP email adapter → ${host}:${port} from ${from}`);
71
+ return nodemailerAdapter({
72
+ defaultFromAddress: from,
73
+ defaultFromName: emailFromName(),
74
+ skipVerify: process.env.NODE_ENV !== 'production',
75
+ transportOptions: {
76
+ host,
77
+ port,
78
+ secure: port === 465,
79
+ auth: user ? { user, pass } : undefined,
80
+ },
81
+ }) as unknown as VitrineEmailAdapter;
82
+ } catch (error) {
83
+ console.warn(
84
+ '[vitrine] @payloadcms/email-nodemailer not installed — falling back to console email adapter',
85
+ error instanceof Error ? error.message : error,
86
+ );
87
+ return consoleEmailAdapter(from);
88
+ }
89
+ }
@@ -0,0 +1,13 @@
1
+ # Feature: filters
2
+
3
+ Catalog toolbar for sort, option facets, and price range via the `catalog.toolbar` slot.
4
+
5
+ ## Modules
6
+ - `components/filters/CatalogToolbar` — GET form updating `?sort=`, `?size=`, `?color=`,
7
+ `?priceMin=` / `?priceMax=` (major currency units) on the current listing URL
8
+ - Mounted into `catalog.toolbar` on home and category pages
9
+
10
+ ## Contracts
11
+ - `ProductQuery.sort` (`newest` | `price-asc` | `price-desc` | `relevance`)
12
+ - `ProductQuery.filters` — option facets, e.g. `{ size: ['S'], color: ['Black'] }`
13
+ - `ProductQuery.priceMin` / `priceMax` — inclusive bounds in minor units (cents)
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "filters",
4
+ "title": "Catalog sort, facets, and price filters",
5
+ "kitVersion": "0.5.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "catalog",
9
+ "simple-store",
10
+ "full-store"
11
+ ],
12
+ "registryDependencies": [
13
+ "catalog"
14
+ ],
15
+ "corePackages": {
16
+ "@vitrine-kit/core": ">=0.1.0"
17
+ },
18
+ "npm": [],
19
+ "files": [
20
+ {
21
+ "from": "files/components/filters/",
22
+ "to": "components/filters/"
23
+ },
24
+ {
25
+ "from": "files/lib/filters/",
26
+ "to": "lib/filters/"
27
+ }
28
+ ],
29
+ "config": {
30
+ "set": {
31
+ "features.filters": true
32
+ }
33
+ },
34
+ "slots": [
35
+ {
36
+ "slot": "catalog.toolbar",
37
+ "component": "CatalogToolbar",
38
+ "order": 10
39
+ }
40
+ ],
41
+ "env": [],
42
+ "claudeDoc": "docs/filters.md",
43
+ "conflicts": [],
44
+ "removable": true
45
+ }
@@ -0,0 +1,123 @@
1
+ // Catalog listing toolbar — sort + option facets + price range. Uses GET so
2
+ // listings stay shareable.
3
+ 'use client';
4
+
5
+ export interface CatalogToolbarProps {
6
+ /** Current sort value from the page query string. */
7
+ sort?: string;
8
+ /** Form action (pathname). Defaults to "/". */
9
+ action?: string;
10
+ /** Available option facets from the current catalog slice. */
11
+ facets?: Record<string, string[]>;
12
+ /** Currently selected option filters. */
13
+ filters?: Record<string, string[]>;
14
+ /** Minor-unit price bounds (string so uncontrolled inputs stay simple). */
15
+ priceMin?: string;
16
+ priceMax?: string;
17
+ }
18
+
19
+ const SORT_OPTIONS: { value: string; label: string }[] = [
20
+ { value: 'newest', label: 'Newest' },
21
+ { value: 'price-asc', label: 'Price: low to high' },
22
+ { value: 'price-desc', label: 'Price: high to low' },
23
+ ];
24
+
25
+ export function CatalogToolbar({
26
+ sort = 'newest',
27
+ action = '/',
28
+ facets = {},
29
+ filters = {},
30
+ priceMin = '',
31
+ priceMax = '',
32
+ }: CatalogToolbarProps) {
33
+ const facetEntries = Object.entries(facets).filter(([, values]) => values.length > 0);
34
+
35
+ return (
36
+ <form
37
+ action={action}
38
+ method="get"
39
+ className="vt-catalog-toolbar flex flex-col gap-gutter"
40
+ >
41
+ <div className="flex flex-wrap items-end gap-gutter">
42
+ <label className="flex items-center gap-unit text-sm text-fg">
43
+ <span className="text-muted-fg">Sort</span>
44
+ <select
45
+ name="sort"
46
+ defaultValue={sort}
47
+ className="rounded-md border border-input bg-surface px-gutter py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
48
+ onChange={(e) => e.currentTarget.form?.requestSubmit()}
49
+ >
50
+ {SORT_OPTIONS.map((opt) => (
51
+ <option key={opt.value} value={opt.value}>
52
+ {opt.label}
53
+ </option>
54
+ ))}
55
+ </select>
56
+ </label>
57
+
58
+ <label className="flex items-center gap-unit text-sm text-fg">
59
+ <span className="text-muted-fg">Min $</span>
60
+ <input
61
+ type="number"
62
+ name="priceMin"
63
+ min={0}
64
+ step={0.01}
65
+ defaultValue={priceMin}
66
+ placeholder="0"
67
+ className="w-24 rounded-md border border-input bg-surface px-gutter py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
68
+ />
69
+ </label>
70
+ <label className="flex items-center gap-unit text-sm text-fg">
71
+ <span className="text-muted-fg">Max $</span>
72
+ <input
73
+ type="number"
74
+ name="priceMax"
75
+ min={0}
76
+ step={0.01}
77
+ defaultValue={priceMax}
78
+ placeholder="—"
79
+ className="w-24 rounded-md border border-input bg-surface px-gutter py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
80
+ />
81
+ </label>
82
+
83
+ <button
84
+ type="submit"
85
+ className="rounded-md border border-border px-gutter py-unit text-sm text-fg hover:bg-muted focus-visible:outline-none focus-visible:ring-2 ring-ring"
86
+ >
87
+ Apply
88
+ </button>
89
+ </div>
90
+
91
+ {facetEntries.length > 0 ? (
92
+ <div className="flex flex-wrap gap-section">
93
+ {facetEntries.map(([key, values]) => (
94
+ <fieldset key={key} className="flex flex-col gap-unit">
95
+ <legend className="text-sm capitalize text-muted-fg">{key}</legend>
96
+ <div className="flex flex-wrap gap-gutter">
97
+ {values.map((value) => {
98
+ const checked = Boolean(filters[key]?.includes(value));
99
+ return (
100
+ <label
101
+ key={value}
102
+ className="flex items-center gap-unit text-sm text-fg"
103
+ >
104
+ <input
105
+ type="checkbox"
106
+ name={key}
107
+ value={value}
108
+ defaultChecked={checked}
109
+ className="rounded border-input"
110
+ onChange={(e) => e.currentTarget.form?.requestSubmit()}
111
+ />
112
+ {value}
113
+ </label>
114
+ );
115
+ })}
116
+ </div>
117
+ </fieldset>
118
+ ))}
119
+ </div>
120
+ ) : null}
121
+ </form>
122
+ );
123
+ }
@@ -0,0 +1,6 @@
1
+ import { registerSlot } from '@vitrine-kit/core';
2
+ import { CatalogToolbar } from '../../components/filters/CatalogToolbar.js';
3
+
4
+ export function registerFiltersSlots(): void {
5
+ registerSlot({ slot: 'catalog.toolbar', component: CatalogToolbar, order: 10 });
6
+ }
@@ -0,0 +1,24 @@
1
+ # Feature: i18n
2
+
3
+ Locale switcher plus optional path-prefix routing when more than one locale is configured.
4
+ When `i18n.locales` lists more than one code, path middleware and the locale switcher
5
+ activate, and the demo seed applies RU product/category copy. Payload always enables
6
+ `localization` from `site.config.i18n` (even for a single locale) so localized fields stay valid.
7
+
8
+ ## Modules
9
+ - `LocaleSwitcher` — header control writing `vitrine_locale` and navigating to `/{locale}/…`
10
+ - `middleware.ts` — when `i18n.locales.length > 1`, redirects bare paths to `/{locale}/…`
11
+ and rewrites to the unprefixed App Router tree (no route file move required)
12
+ - `lib/i18n/dictionary.ts` — en/ru dictionaries for shared chrome strings
13
+ - `lib/i18n/useChromeLabel.ts` — client hook used by cart/search/wishlist/account chrome
14
+ - `lib/i18n/t.ts` — re-exports `t` / `MessageKey`
15
+ - `lib/i18n/locale.ts` — `getRequestLocale()` from the cookie (used by catalog pages)
16
+
17
+ The base template ships an English-only dictionary stub so chrome can always import
18
+ `@/lib/i18n/*`; installing this feature overwrites it with multi-locale copy.
19
+
20
+ ## Setup
21
+ Set `site.config` `i18n.locales` to more than one locale (e.g. `['en','ru']`). The switcher
22
+ hides itself when only one locale is configured; middleware is a no-op in that case.
23
+ Payload `localization` mirrors that list; mark product/category title and description as
24
+ localized in the blueprint. Demo seed applies RU copy when `ru` is enabled.
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "i18n",
4
+ "title": "Locale switcher and path prefixes",
5
+ "kitVersion": "0.5.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "catalog",
9
+ "simple-store",
10
+ "full-store"
11
+ ],
12
+ "registryDependencies": [],
13
+ "corePackages": {
14
+ "@vitrine-kit/core": ">=0.1.0"
15
+ },
16
+ "npm": [],
17
+ "files": [
18
+ {
19
+ "from": "files/components/i18n/",
20
+ "to": "components/i18n/"
21
+ },
22
+ {
23
+ "from": "files/lib/i18n/",
24
+ "to": "lib/i18n/"
25
+ },
26
+ {
27
+ "from": "files/middleware.ts",
28
+ "to": "middleware.ts"
29
+ }
30
+ ],
31
+ "config": {
32
+ "set": {
33
+ "features.i18n": true
34
+ }
35
+ },
36
+ "slots": [
37
+ {
38
+ "slot": "global.header-actions",
39
+ "component": "LocaleSwitcher",
40
+ "order": 30
41
+ }
42
+ ],
43
+ "env": [],
44
+ "claudeDoc": "docs/i18n.md",
45
+ "conflicts": [],
46
+ "removable": true
47
+ }
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ const COOKIE = 'vitrine_locale';
5
+
6
+ function readCookie(): string | null {
7
+ if (typeof document === 'undefined') return null;
8
+ const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]*)`));
9
+ return match ? decodeURIComponent(match[1]!) : null;
10
+ }
11
+
12
+ function writeCookie(locale: string): void {
13
+ // biome-ignore lint/suspicious/noDocumentCookie: locale preference cookie; Cookie Store API is not universal enough yet.
14
+ document.cookie = `${COOKIE}=${encodeURIComponent(locale)};path=/;max-age=31536000;samesite=lax`;
15
+ }
16
+
17
+ /** Strip a leading `/{locale}` segment when present. */
18
+ function stripLocale(pathname: string, locales: string[]): string {
19
+ const seg = pathname.split('/')[1] ?? '';
20
+ if (locales.includes(seg)) {
21
+ return pathname.slice(seg.length + 1) || '/';
22
+ }
23
+ return pathname || '/';
24
+ }
25
+
26
+ /** Locales come from site.config via optional prop; falls back to en/ru demo pair. */
27
+ export interface LocaleSwitcherProps {
28
+ locales?: string[];
29
+ defaultLocale?: string;
30
+ }
31
+
32
+ export function LocaleSwitcher({ locales, defaultLocale = 'en' }: LocaleSwitcherProps) {
33
+ const options = locales && locales.length > 0 ? locales : ['en', 'ru'];
34
+ const [locale, setLocale] = useState(defaultLocale);
35
+
36
+ useEffect(() => {
37
+ setLocale(readCookie() ?? defaultLocale);
38
+ }, [defaultLocale]);
39
+
40
+ if (options.length < 2) return null;
41
+
42
+ return (
43
+ <label className="vt-locale-switcher flex items-center gap-unit text-sm text-fg">
44
+ <span className="sr-only">Language</span>
45
+ <select
46
+ value={locale}
47
+ className="rounded-md border border-input bg-surface px-unit py-unit text-surface-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
48
+ onChange={(e) => {
49
+ const next = e.target.value;
50
+ writeCookie(next);
51
+ setLocale(next);
52
+ const rest = stripLocale(location.pathname, options);
53
+ location.assign(rest === '/' ? `/${next}` : `/${next}${rest}`);
54
+ }}
55
+ >
56
+ {options.map((code) => (
57
+ <option key={code} value={code}>
58
+ {code.toUpperCase()}
59
+ </option>
60
+ ))}
61
+ </select>
62
+ </label>
63
+ );
64
+ }
@@ -0,0 +1,52 @@
1
+ // Multi-locale chrome dictionary. Overwrites the base English stub when `i18n` is installed.
2
+ export type MessageKey =
3
+ | 'cart'
4
+ | 'search'
5
+ | 'checkout'
6
+ | 'wishlist'
7
+ | 'orders'
8
+ | 'account'
9
+ | 'continueShopping'
10
+ | 'addToCart'
11
+ | 'thankYou'
12
+ | 'signIn'
13
+ | 'createAccount';
14
+
15
+ const en: Record<MessageKey, string> = {
16
+ cart: 'Cart',
17
+ search: 'Search',
18
+ checkout: 'Checkout',
19
+ wishlist: 'Wishlist',
20
+ orders: 'Orders',
21
+ account: 'Account',
22
+ continueShopping: 'Continue shopping',
23
+ addToCart: 'Add to cart',
24
+ thankYou: 'Thank you for your order',
25
+ signIn: 'Sign in',
26
+ createAccount: 'Create account',
27
+ };
28
+
29
+ const ru: Record<MessageKey, string> = {
30
+ cart: 'Корзина',
31
+ search: 'Поиск',
32
+ checkout: 'Оформить',
33
+ wishlist: 'Избранное',
34
+ orders: 'Заказы',
35
+ account: 'Аккаунт',
36
+ continueShopping: 'Продолжить покупки',
37
+ addToCart: 'В корзину',
38
+ thankYou: 'Спасибо за заказ',
39
+ signIn: 'Войти',
40
+ createAccount: 'Создать аккаунт',
41
+ };
42
+
43
+ const dictionaries: Record<string, Record<MessageKey, string>> = { en, ru };
44
+
45
+ export function t(key: MessageKey, locale = 'en'): string {
46
+ const dict = dictionaries[locale] ?? en;
47
+ return dict[key] ?? en[key] ?? key;
48
+ }
49
+
50
+ export function supportedLocales(): string[] {
51
+ return Object.keys(dictionaries);
52
+ }
@@ -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,6 @@
1
+ import { registerSlot } from '@vitrine-kit/core';
2
+ import { LocaleSwitcher } from '../../components/i18n/LocaleSwitcher.js';
3
+
4
+ export function registerI18nSlots(): void {
5
+ registerSlot({ slot: 'global.header-actions', component: LocaleSwitcher, order: 30 });
6
+ }
@@ -0,0 +1 @@
1
+ export { t, type MessageKey, supportedLocales } from './dictionary.js';