@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,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
|
+
}
|
|
@@ -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,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 @@
|
|
|
1
|
+
export { t, type MessageKey, supportedLocales } from './dictionary.js';
|