@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
|
@@ -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';
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Locale path middleware: when site.config has multiple locales, prefix URLs
|
|
2
|
+
// (`/en/cart`) and rewrite internally to the unprefixed App Router tree.
|
|
3
|
+
// No-op when only one locale is configured.
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import type { NextRequest } from 'next/server';
|
|
6
|
+
import { siteConfig } from './site.config';
|
|
7
|
+
|
|
8
|
+
const COOKIE = 'vitrine_locale';
|
|
9
|
+
|
|
10
|
+
export function middleware(req: NextRequest) {
|
|
11
|
+
const locales = siteConfig.i18n.locales;
|
|
12
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
13
|
+
if (locales.length < 2) return NextResponse.next();
|
|
14
|
+
|
|
15
|
+
const { pathname } = req.nextUrl;
|
|
16
|
+
|
|
17
|
+
// Skip Next internals, Payload admin/API, and static assets.
|
|
18
|
+
if (
|
|
19
|
+
pathname.startsWith('/_next') ||
|
|
20
|
+
pathname.startsWith('/api') ||
|
|
21
|
+
pathname.startsWith('/admin') ||
|
|
22
|
+
pathname.includes('.') // files with extensions
|
|
23
|
+
) {
|
|
24
|
+
return NextResponse.next();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const seg = pathname.split('/')[1] ?? '';
|
|
28
|
+
const hasLocale = locales.includes(seg);
|
|
29
|
+
|
|
30
|
+
if (!hasLocale) {
|
|
31
|
+
const cookieLocale = req.cookies.get(COOKIE)?.value;
|
|
32
|
+
const locale = cookieLocale && locales.includes(cookieLocale) ? cookieLocale : defaultLocale;
|
|
33
|
+
const url = req.nextUrl.clone();
|
|
34
|
+
url.pathname = pathname === '/' ? `/${locale}` : `/${locale}${pathname}`;
|
|
35
|
+
const res = NextResponse.redirect(url);
|
|
36
|
+
res.cookies.set(COOKIE, locale, { path: '/', maxAge: 31536000, sameSite: 'lax' });
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const locale = seg;
|
|
41
|
+
const stripped = pathname.slice(locale.length + 1) || '/';
|
|
42
|
+
const rewrite = req.nextUrl.clone();
|
|
43
|
+
rewrite.pathname = stripped;
|
|
44
|
+
const res = NextResponse.rewrite(rewrite);
|
|
45
|
+
res.cookies.set(COOKIE, locale, { path: '/', maxAge: 31536000, sameSite: 'lax' });
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const config = {
|
|
50
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
51
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// Product gallery —
|
|
1
|
+
// Product gallery — client component so thumbnails switch the cover image.
|
|
2
|
+
'use client';
|
|
3
|
+
import { useState } from 'react';
|
|
2
4
|
import type { ProductImage } from '@vitrine-kit/contracts';
|
|
3
5
|
|
|
4
6
|
export interface ProductGalleryProps {
|
|
@@ -7,11 +9,12 @@ export interface ProductGalleryProps {
|
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export function ProductGallery({ images, title }: ProductGalleryProps) {
|
|
10
|
-
const
|
|
12
|
+
const [active, setActive] = useState(0);
|
|
13
|
+
const cover = images[active] ?? images[0];
|
|
11
14
|
if (!cover) {
|
|
12
15
|
return <div className="aspect-square w-full rounded-md bg-muted" aria-hidden="true" />;
|
|
13
16
|
}
|
|
14
|
-
|
|
17
|
+
|
|
15
18
|
return (
|
|
16
19
|
<div className="vt-product-gallery">
|
|
17
20
|
<img
|
|
@@ -19,15 +22,25 @@ export function ProductGallery({ images, title }: ProductGalleryProps) {
|
|
|
19
22
|
alt={cover.alt ?? title}
|
|
20
23
|
className="w-full rounded-md object-cover"
|
|
21
24
|
/>
|
|
22
|
-
{
|
|
25
|
+
{images.length > 1 ? (
|
|
23
26
|
<ul role="list" className="mt-gutter flex gap-gutter">
|
|
24
|
-
{
|
|
27
|
+
{images.map((img, i) => (
|
|
25
28
|
<li key={img.url}>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={() => setActive(i)}
|
|
32
|
+
aria-label={`Show image ${i + 1}`}
|
|
33
|
+
aria-current={i === active ? 'true' : undefined}
|
|
34
|
+
className={`rounded-md focus-visible:outline-none focus-visible:ring-2 ring-ring ${
|
|
35
|
+
i === active ? 'ring-2 ring-ring' : 'opacity-80 hover:opacity-100'
|
|
36
|
+
}`}
|
|
37
|
+
>
|
|
38
|
+
<img
|
|
39
|
+
src={img.url}
|
|
40
|
+
alt={img.alt ?? `${title} — image ${i + 1}`}
|
|
41
|
+
className="h-16 w-16 rounded-md object-cover"
|
|
42
|
+
/>
|
|
43
|
+
</button>
|
|
31
44
|
</li>
|
|
32
45
|
))}
|
|
33
46
|
</ul>
|
|
@@ -25,17 +25,21 @@ export function ProductView({ product }: ProductViewProps) {
|
|
|
25
25
|
<Slot name="product.below-title" />
|
|
26
26
|
<h1 className="font-heading text-fg">{product.title}</h1>
|
|
27
27
|
{price != null ? (
|
|
28
|
-
<p className="text-price text-xl">
|
|
28
|
+
<p className="text-price text-xl">
|
|
29
|
+
{product.priceRange && product.priceRange.min !== product.priceRange.max
|
|
30
|
+
? `${formatPrice(product.priceRange.min, currency)} – ${formatPrice(product.priceRange.max, currency)}`
|
|
31
|
+
: formatPrice(price, currency)}
|
|
32
|
+
</p>
|
|
29
33
|
) : null}
|
|
30
|
-
<Slot name="product.below-price" />
|
|
34
|
+
<Slot name="product.below-price" product={product} />
|
|
31
35
|
<div className="vt-product-purchase">
|
|
32
36
|
<Slot name="product.purchase" product={product} />
|
|
33
37
|
</div>
|
|
34
38
|
{product.description ? (
|
|
35
39
|
<div className="vt-product-description text-fg">{product.description}</div>
|
|
36
40
|
) : null}
|
|
37
|
-
<Slot name="product.below-description" />
|
|
38
|
-
<Slot name="product.tabs" />
|
|
41
|
+
<Slot name="product.below-description" product={product} />
|
|
42
|
+
<Slot name="product.tabs" product={product} />
|
|
39
43
|
</div>
|
|
40
44
|
</article>
|
|
41
45
|
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Feature: reviews
|
|
2
|
+
|
|
3
|
+
Customer reviews stored as a JSON array on the product document (`reviews` field via blueprint).
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
- `ReviewList` — renders existing reviews + form (`product.below-description`)
|
|
7
|
+
- `POST /api/reviews` — appends a review (rate-limited)
|
|
8
|
+
- `lib/reviews/blueprint.ts` — additive `reviews` json field on products
|
|
9
|
+
|
|
10
|
+
## Note
|
|
11
|
+
Moderation is not included — submissions are public immediately. Tighten access before production.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "reviews",
|
|
4
|
+
"title": "Product reviews",
|
|
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
|
+
"product-page"
|
|
14
|
+
],
|
|
15
|
+
"corePackages": {
|
|
16
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"npm": [],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"from": "files/components/reviews/",
|
|
22
|
+
"to": "components/reviews/"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"from": "files/lib/reviews/",
|
|
26
|
+
"to": "lib/reviews/"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"from": "files/app/",
|
|
30
|
+
"to": "app/"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"config": {
|
|
34
|
+
"set": {
|
|
35
|
+
"features.reviews": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"slots": [
|
|
39
|
+
{
|
|
40
|
+
"slot": "product.below-description",
|
|
41
|
+
"component": "ReviewList",
|
|
42
|
+
"order": 20
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"blueprint": {
|
|
46
|
+
"extend": "product",
|
|
47
|
+
"addFields": [
|
|
48
|
+
"reviews"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"env": [],
|
|
52
|
+
"claudeDoc": "docs/reviews.md",
|
|
53
|
+
"conflicts": [],
|
|
54
|
+
"removable": true
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// POST /api/reviews — append a review onto the product.reviews JSON array.
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { getPayload } from 'payload';
|
|
4
|
+
import config from '@payload-config';
|
|
5
|
+
import { checkRateLimit, clientIpFromHeaders } from '@vitrine-kit/core';
|
|
6
|
+
import { parseReviews, type ProductReview } from '@/lib/reviews/types';
|
|
7
|
+
|
|
8
|
+
const RATE_LIMIT = { limit: 10, windowMs: 60_000 };
|
|
9
|
+
|
|
10
|
+
export async function POST(req: Request) {
|
|
11
|
+
const { allowed, retryAfterMs } = checkRateLimit(`reviews:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
12
|
+
if (!allowed) {
|
|
13
|
+
return NextResponse.json(
|
|
14
|
+
{ error: 'too many requests' },
|
|
15
|
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 0) / 1000)) } },
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const body = (await req.json().catch(() => null)) as {
|
|
20
|
+
productId?: unknown;
|
|
21
|
+
author?: unknown;
|
|
22
|
+
rating?: unknown;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
} | null;
|
|
25
|
+
const productId = typeof body?.productId === 'string' ? body.productId : '';
|
|
26
|
+
const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 80) : '';
|
|
27
|
+
const text = typeof body?.body === 'string' ? body.body.trim().slice(0, 2000) : '';
|
|
28
|
+
const rating = typeof body?.rating === 'number' ? body.rating : Number(body?.rating);
|
|
29
|
+
if (!productId || !author || !text || !Number.isInteger(rating) || rating < 1 || rating > 5) {
|
|
30
|
+
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const payload = await getPayload({ config });
|
|
34
|
+
const doc = await payload.findByID({ collection: 'products', id: productId, depth: 0 }).catch(() => null);
|
|
35
|
+
if (!doc) return NextResponse.json({ error: 'product not found' }, { status: 404 });
|
|
36
|
+
|
|
37
|
+
const review: ProductReview = {
|
|
38
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
39
|
+
author,
|
|
40
|
+
rating,
|
|
41
|
+
body: text,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
const existing = parseReviews((doc as { reviews?: unknown }).reviews);
|
|
45
|
+
const reviews = [review, ...existing].slice(0, 100);
|
|
46
|
+
|
|
47
|
+
await payload.update({
|
|
48
|
+
collection: 'products',
|
|
49
|
+
id: productId,
|
|
50
|
+
data: { reviews },
|
|
51
|
+
overrideAccess: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return NextResponse.json({ review });
|
|
55
|
+
}
|