@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,8 @@
|
|
|
1
|
+
// Search via CatalogSource.search — portable across Payload and Vendure adapters.
|
|
2
|
+
import type { CatalogSource, Product } from '@vitrine-kit/contracts';
|
|
3
|
+
|
|
4
|
+
export async function loadSearch(source: CatalogSource, term: string): Promise<Product[]> {
|
|
5
|
+
const q = term.trim();
|
|
6
|
+
if (!q) return [];
|
|
7
|
+
return source.search(q);
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Registers the search feature's slots. SearchForm sits in the header actions
|
|
2
|
+
// (before CartIndicator when cart is installed).
|
|
3
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
4
|
+
import { SearchForm } from '../../components/search/SearchForm.js';
|
|
5
|
+
|
|
6
|
+
export function registerSearchSlots(): void {
|
|
7
|
+
registerSlot({ slot: 'global.header-actions', component: SearchForm, order: 5 });
|
|
8
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# Feature: seo
|
|
2
2
|
|
|
3
|
-
Page metadata and schema.org JSON-LD from domain types.
|
|
3
|
+
Page metadata and schema.org JSON-LD from domain types, plus sitewide crawl files.
|
|
4
4
|
|
|
5
5
|
## Modules
|
|
6
6
|
- `lib/seo/metadata` — `buildProductMetadata(product)` → `SiteMetadata` (structurally compatible with Next `Metadata`; in the client it's returned from `generateMetadata`).
|
|
7
7
|
- `lib/seo/jsonld` — `productJsonLd(product)` → a schema.org `Product`/`Offer` object.
|
|
8
8
|
- `components/seo/JsonLd` — `<JsonLd data={…} />` embeds a `<script type="application/ld+json">`.
|
|
9
|
+
- `app/sitemap.ts` — dynamic sitemap from `CatalogSource` (home, categories, products).
|
|
10
|
+
- `app/robots.ts` — robots.txt pointing at the sitemap; disallows admin/api/cart/order.
|
|
9
11
|
|
|
10
12
|
## Contracts
|
|
11
13
|
Only `@vitrine-kit/contracts` (the `Product` type). Registers no slots.
|
|
12
14
|
|
|
13
15
|
## Note
|
|
14
16
|
The price in JSON-LD is converted from minor units (`price/100`).
|
|
17
|
+
Home and category pages set `generateMetadata` in the base template.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "../../schemas/feature.schema.json",
|
|
3
3
|
"name": "seo",
|
|
4
4
|
"title": "SEO — metadata and JSON-LD",
|
|
5
|
-
"kitVersion": "0.
|
|
5
|
+
"kitVersion": "0.5.0",
|
|
6
6
|
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
7
|
"tier": [
|
|
8
8
|
"catalog",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
{
|
|
21
21
|
"from": "files/lib/seo/",
|
|
22
22
|
"to": "lib/seo/"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"from": "files/app/",
|
|
26
|
+
"to": "app/"
|
|
23
27
|
}
|
|
24
28
|
],
|
|
25
29
|
"config": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { baseUrl } from '@/lib/site';
|
|
3
|
+
|
|
4
|
+
export default function robots(): MetadataRoute.Robots {
|
|
5
|
+
return {
|
|
6
|
+
rules: {
|
|
7
|
+
userAgent: '*',
|
|
8
|
+
allow: '/',
|
|
9
|
+
disallow: ['/admin/', '/api/', '/cart', '/order/', '/account', '/wishlist'],
|
|
10
|
+
},
|
|
11
|
+
sitemap: `${baseUrl}/sitemap.xml`,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { getCatalogSource } from '@/lib/adapter';
|
|
3
|
+
import { baseUrl } from '@/lib/site';
|
|
4
|
+
|
|
5
|
+
/** Dynamic sitemap from the catalog (products + categories + home). */
|
|
6
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
7
|
+
const source = await getCatalogSource();
|
|
8
|
+
const [products, categories] = await Promise.all([
|
|
9
|
+
source.listProducts({ perPage: 500 }),
|
|
10
|
+
source.listCategories(),
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const entries: MetadataRoute.Sitemap = [
|
|
14
|
+
{ url: baseUrl, changeFrequency: 'daily', priority: 1 },
|
|
15
|
+
...categories.map((c) => ({
|
|
16
|
+
url: `${baseUrl}/categories/${c.slug}`,
|
|
17
|
+
changeFrequency: 'weekly' as const,
|
|
18
|
+
priority: 0.7,
|
|
19
|
+
})),
|
|
20
|
+
...products.map((p) => ({
|
|
21
|
+
url: `${baseUrl}/products/${p.slug}`,
|
|
22
|
+
changeFrequency: 'weekly' as const,
|
|
23
|
+
priority: 0.8,
|
|
24
|
+
})),
|
|
25
|
+
];
|
|
26
|
+
return entries;
|
|
27
|
+
}
|
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
declare module 'next/server' {
|
|
5
5
|
export class NextResponse {
|
|
6
6
|
static json(body: unknown, init?: ResponseInit): NextResponse;
|
|
7
|
+
static next(): NextResponse;
|
|
8
|
+
static redirect(url: URL | string): NextResponse;
|
|
9
|
+
static rewrite(url: URL | string): NextResponse;
|
|
10
|
+
cookies: {
|
|
11
|
+
set(name: string, value: string, options?: Record<string, unknown>): void;
|
|
12
|
+
};
|
|
7
13
|
}
|
|
14
|
+
export interface NextRequest {
|
|
15
|
+
nextUrl: URL & { clone(): URL & { pathname: string } };
|
|
16
|
+
cookies: { get(name: string): { value: string } | undefined };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module 'next/navigation' {
|
|
21
|
+
export function useRouter(): {
|
|
22
|
+
push(href: string): void;
|
|
23
|
+
refresh(): void;
|
|
24
|
+
};
|
|
8
25
|
}
|
|
9
26
|
|
|
10
27
|
declare module 'next/headers' {
|
|
@@ -43,3 +60,57 @@ declare module '@/lib/checkout-yookassa/provider' {
|
|
|
43
60
|
import type { PaymentProvider } from '@vitrine-kit/core';
|
|
44
61
|
export const yookassaProvider: PaymentProvider;
|
|
45
62
|
}
|
|
63
|
+
|
|
64
|
+
declare module 'payload' {
|
|
65
|
+
export function getPayload(args: { config: unknown }): Promise<{
|
|
66
|
+
findByID(args: Record<string, unknown>): Promise<Record<string, unknown> | null>;
|
|
67
|
+
find(args: Record<string, unknown>): Promise<{ docs: Array<Record<string, unknown>> }>;
|
|
68
|
+
update(args: Record<string, unknown>): Promise<unknown>;
|
|
69
|
+
create(args: Record<string, unknown>): Promise<unknown>;
|
|
70
|
+
}>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
declare module '@payload-config' {
|
|
74
|
+
const config: unknown;
|
|
75
|
+
export default config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
declare module '@/site.config' {
|
|
79
|
+
import type { SiteConfig } from '@vitrine-kit/contracts';
|
|
80
|
+
export const siteConfig: SiteConfig;
|
|
81
|
+
export default siteConfig;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
declare module '@/lib/i18n/dictionary' {
|
|
85
|
+
export type MessageKey =
|
|
86
|
+
| 'cart'
|
|
87
|
+
| 'search'
|
|
88
|
+
| 'checkout'
|
|
89
|
+
| 'wishlist'
|
|
90
|
+
| 'orders'
|
|
91
|
+
| 'account'
|
|
92
|
+
| 'continueShopping'
|
|
93
|
+
| 'addToCart'
|
|
94
|
+
| 'thankYou'
|
|
95
|
+
| 'signIn'
|
|
96
|
+
| 'createAccount';
|
|
97
|
+
export function t(key: MessageKey, locale?: string): string;
|
|
98
|
+
export function supportedLocales(): string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
declare module '@/lib/i18n/useChromeLabel' {
|
|
102
|
+
import type { MessageKey } from '@/lib/i18n/dictionary';
|
|
103
|
+
export function useChromeLabel(key: MessageKey, fallbackLocale?: string): string;
|
|
104
|
+
export function chromeLabel(key: MessageKey, locale?: string): string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
declare module '@/lib/reviews/types' {
|
|
108
|
+
export interface ProductReview {
|
|
109
|
+
id: string;
|
|
110
|
+
author: string;
|
|
111
|
+
rating: number;
|
|
112
|
+
body: string;
|
|
113
|
+
createdAt: string;
|
|
114
|
+
}
|
|
115
|
+
export function parseReviews(value: unknown): ProductReview[];
|
|
116
|
+
}
|
|
@@ -10,12 +10,17 @@
|
|
|
10
10
|
"*/files/components/**/*.ts",
|
|
11
11
|
"*/files/components/**/*.tsx",
|
|
12
12
|
"*/files/lib/**/*.ts",
|
|
13
|
-
"*/files/lib/**/*.tsx"
|
|
13
|
+
"*/files/lib/**/*.tsx",
|
|
14
|
+
"stubs/**/*.d.ts"
|
|
14
15
|
],
|
|
15
16
|
"exclude": [
|
|
16
17
|
"**/node_modules",
|
|
17
18
|
"**/dist",
|
|
18
19
|
"*/files/lib/checkout-*/**",
|
|
19
|
-
"*/files/lib/checkout/fulfill.ts"
|
|
20
|
+
"*/files/lib/checkout/fulfill.ts",
|
|
21
|
+
"*/files/lib/checkout/notify.ts",
|
|
22
|
+
"*/files/lib/email/**",
|
|
23
|
+
"*/files/lib/accounts/session.ts",
|
|
24
|
+
"*/files/middleware.ts"
|
|
20
25
|
]
|
|
21
26
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Feature: wishlist
|
|
2
|
+
|
|
3
|
+
Client-side wishlist (localStorage). No backend — works on every tier including catalog-only.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
- `WishlistButton` — toggle on the product page (`product.below-price`)
|
|
7
|
+
- `WishlistIndicator` — header link with count (`global.header-actions`)
|
|
8
|
+
- `/wishlist` — list of saved product slugs resolved via `CatalogSource`
|
|
9
|
+
|
|
10
|
+
## Note
|
|
11
|
+
Guest-only. Clearing site data clears the wishlist. Accounts can later sync this storage.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "wishlist",
|
|
4
|
+
"title": "Wishlist",
|
|
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
|
+
"product-page"
|
|
15
|
+
],
|
|
16
|
+
"corePackages": {
|
|
17
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"npm": [],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"from": "files/components/wishlist/",
|
|
23
|
+
"to": "components/wishlist/"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"from": "files/lib/wishlist/",
|
|
27
|
+
"to": "lib/wishlist/"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"from": "files/app/",
|
|
31
|
+
"to": "app/"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"config": {
|
|
35
|
+
"set": {
|
|
36
|
+
"features.wishlist": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"slots": [
|
|
40
|
+
{
|
|
41
|
+
"slot": "product.below-price",
|
|
42
|
+
"component": "WishlistButton",
|
|
43
|
+
"order": 20
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"slot": "global.header-actions",
|
|
47
|
+
"component": "WishlistIndicator",
|
|
48
|
+
"order": 15
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"env": [],
|
|
52
|
+
"claudeDoc": "docs/wishlist.md",
|
|
53
|
+
"conflicts": [],
|
|
54
|
+
"removable": true
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { WishlistView } from '@/components/wishlist/WishlistView';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Wishlist',
|
|
6
|
+
robots: { index: false, follow: true },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function WishlistPage() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col gap-section">
|
|
12
|
+
<h1 className="font-heading text-fg">Wishlist</h1>
|
|
13
|
+
<WishlistView />
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
4
|
+
import { isWishlisted, toggleWishlist } from '../../lib/wishlist/storage.js';
|
|
5
|
+
|
|
6
|
+
export interface WishlistButtonProps {
|
|
7
|
+
product?: Product;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function WishlistButton({ product }: WishlistButtonProps) {
|
|
11
|
+
const slug = product?.slug;
|
|
12
|
+
const [on, setOn] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!slug) return;
|
|
16
|
+
setOn(isWishlisted(slug));
|
|
17
|
+
const sync = () => setOn(isWishlisted(slug));
|
|
18
|
+
window.addEventListener('vitrine:wishlist', sync);
|
|
19
|
+
window.addEventListener('storage', sync);
|
|
20
|
+
return () => {
|
|
21
|
+
window.removeEventListener('vitrine:wishlist', sync);
|
|
22
|
+
window.removeEventListener('storage', sync);
|
|
23
|
+
};
|
|
24
|
+
}, [slug]);
|
|
25
|
+
|
|
26
|
+
if (!slug) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className="vt-wishlist-button w-fit text-sm text-muted-fg underline underline-offset-2 transition hover:text-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
32
|
+
onClick={() => setOn(toggleWishlist(slug).includes(slug))}
|
|
33
|
+
>
|
|
34
|
+
{on ? 'Remove from wishlist' : 'Add to wishlist'}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { readWishlist } from '../../lib/wishlist/storage.js';
|
|
4
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
5
|
+
|
|
6
|
+
export function WishlistIndicator() {
|
|
7
|
+
const [count, setCount] = useState(0);
|
|
8
|
+
const label = useChromeLabel('wishlist');
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const sync = () => setCount(readWishlist().length);
|
|
12
|
+
sync();
|
|
13
|
+
window.addEventListener('vitrine:wishlist', sync);
|
|
14
|
+
window.addEventListener('storage', sync);
|
|
15
|
+
return () => {
|
|
16
|
+
window.removeEventListener('vitrine:wishlist', sync);
|
|
17
|
+
window.removeEventListener('storage', sync);
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<a
|
|
23
|
+
href="/wishlist"
|
|
24
|
+
className="vt-wishlist-indicator text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
25
|
+
>
|
|
26
|
+
{label}
|
|
27
|
+
{count > 0 ? ` (${count})` : ''}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { readWishlist, writeWishlist } from '../../lib/wishlist/storage.js';
|
|
4
|
+
|
|
5
|
+
interface WishItem {
|
|
6
|
+
slug: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function WishlistView() {
|
|
11
|
+
const [items, setItems] = useState<WishItem[]>([]);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const slugs = readWishlist();
|
|
16
|
+
if (slugs.length === 0) {
|
|
17
|
+
setItems([]);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
Promise.all(
|
|
22
|
+
slugs.map(async (slug) => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`/api/products?where[slug][equals]=${encodeURIComponent(slug)}&limit=1`);
|
|
25
|
+
if (!res.ok) return { slug };
|
|
26
|
+
const data = (await res.json()) as { docs?: Array<{ title?: string }> };
|
|
27
|
+
return { slug, title: data.docs?.[0]?.title };
|
|
28
|
+
} catch {
|
|
29
|
+
return { slug };
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
).then((list) => {
|
|
33
|
+
setItems(list);
|
|
34
|
+
setLoading(false);
|
|
35
|
+
});
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
if (loading) return <p className="text-muted-fg">Loading wishlist…</p>;
|
|
39
|
+
|
|
40
|
+
if (items.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col gap-gutter">
|
|
43
|
+
<p className="text-muted-fg">Your wishlist is empty.</p>
|
|
44
|
+
<a href="/" className="w-fit text-fg underline underline-offset-2">
|
|
45
|
+
Browse catalog
|
|
46
|
+
</a>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ul role="list" className="flex flex-col gap-gutter">
|
|
53
|
+
{items.map((item) => (
|
|
54
|
+
<li key={item.slug} className="flex items-center justify-between gap-gutter border-b border-border py-unit">
|
|
55
|
+
<a href={`/products/${item.slug}`} className="text-fg underline-offset-2 hover:underline">
|
|
56
|
+
{item.title ?? item.slug}
|
|
57
|
+
</a>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="text-sm text-muted-fg underline underline-offset-2 hover:text-fg"
|
|
61
|
+
onClick={() => {
|
|
62
|
+
const next = readWishlist().filter((s) => s !== item.slug);
|
|
63
|
+
writeWishlist(next);
|
|
64
|
+
setItems((prev) => prev.filter((p) => p.slug !== item.slug));
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
Remove
|
|
68
|
+
</button>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
2
|
+
import { WishlistButton } from '../../components/wishlist/WishlistButton.js';
|
|
3
|
+
import { WishlistIndicator } from '../../components/wishlist/WishlistIndicator.js';
|
|
4
|
+
|
|
5
|
+
export function registerWishlistSlots(): void {
|
|
6
|
+
registerSlot({ slot: 'product.below-price', component: WishlistButton, order: 20 });
|
|
7
|
+
registerSlot({ slot: 'global.header-actions', component: WishlistIndicator, order: 15 });
|
|
8
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// localStorage wishlist of product slugs. Pure browser helper — no server.
|
|
2
|
+
const KEY = 'vitrine_wishlist';
|
|
3
|
+
|
|
4
|
+
export function readWishlist(): string[] {
|
|
5
|
+
if (typeof window === 'undefined') return [];
|
|
6
|
+
try {
|
|
7
|
+
const raw = window.localStorage.getItem(KEY);
|
|
8
|
+
if (!raw) return [];
|
|
9
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
10
|
+
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeWishlist(slugs: string[]): void {
|
|
17
|
+
window.localStorage.setItem(KEY, JSON.stringify([...new Set(slugs)]));
|
|
18
|
+
window.dispatchEvent(new Event('vitrine:wishlist'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleWishlist(slug: string): string[] {
|
|
22
|
+
const current = readWishlist();
|
|
23
|
+
const next = current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug];
|
|
24
|
+
writeWishlist(next);
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isWishlisted(slug: string): boolean {
|
|
29
|
+
return readWishlist().includes(slug);
|
|
30
|
+
}
|
|
@@ -25,5 +25,8 @@ WORKDIR /app
|
|
|
25
25
|
COPY --from=build /app/public ./public
|
|
26
26
|
COPY --from=build /app/.next/standalone ./
|
|
27
27
|
COPY --from=build /app/.next/static ./.next/static
|
|
28
|
+
# Demo seed + uploads (SEED_ON_BOOT); media is also a named volume in compose.
|
|
29
|
+
COPY --from=build /app/seed-assets ./seed-assets
|
|
30
|
+
RUN mkdir -p /app/media
|
|
28
31
|
EXPOSE 3000
|
|
29
32
|
CMD ["node", "server.js"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Reference Vitrine deployment on a VPS: app (Next+Payload) + Postgres.
|
|
2
2
|
# Run: PAYLOAD_SECRET=... docker compose up --build
|
|
3
|
+
# First boot seeds demo catalog + admin when SEED_ON_BOOT=1 (default here).
|
|
3
4
|
services:
|
|
4
5
|
db:
|
|
5
6
|
image: postgres:16-alpine
|
|
@@ -25,11 +26,18 @@ services:
|
|
|
25
26
|
DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
|
|
26
27
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:?set PAYLOAD_SECRET}
|
|
27
28
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
|
29
|
+
SEED_ON_BOOT: ${SEED_ON_BOOT:-1}
|
|
30
|
+
DEV_ADMIN_EMAIL: ${DEV_ADMIN_EMAIL:-admin@example.com}
|
|
31
|
+
DEV_ADMIN_PASSWORD: ${DEV_ADMIN_PASSWORD:-}
|
|
32
|
+
EMAIL_FROM: ${EMAIL_FROM:-noreply@localhost}
|
|
28
33
|
ports:
|
|
29
34
|
- '3000:3000'
|
|
35
|
+
volumes:
|
|
36
|
+
- media:/app/media
|
|
30
37
|
depends_on:
|
|
31
38
|
db:
|
|
32
39
|
condition: service_healthy
|
|
33
40
|
|
|
34
41
|
volumes:
|
|
35
42
|
pgdata:
|
|
43
|
+
media:
|
|
@@ -9,19 +9,25 @@ import type {
|
|
|
9
9
|
ProductSort,
|
|
10
10
|
} from '@vitrine-kit/contracts';
|
|
11
11
|
import { mapCategory, mapProduct } from './map.js';
|
|
12
|
+
import { applyProductQuery } from './product-query.js';
|
|
12
13
|
import type { ProductDoc, VariantDoc } from './payload-types.js';
|
|
13
14
|
|
|
14
15
|
function sortExpr(sort?: ProductSort): string {
|
|
16
|
+
// Price sorts are applied in memory after variants load (see applyProductQuery).
|
|
15
17
|
switch (sort) {
|
|
16
18
|
case 'price-asc':
|
|
17
|
-
return 'createdAt'; // price lives in the variants collection — price sorting is provided by the filters feature
|
|
18
19
|
case 'price-desc':
|
|
20
|
+
case 'relevance':
|
|
19
21
|
return '-createdAt';
|
|
20
|
-
default:
|
|
22
|
+
default:
|
|
21
23
|
return '-createdAt';
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function localeOpt(locale?: string): { locale?: string } {
|
|
28
|
+
return locale ? { locale } : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export class PayloadCatalogSource implements CatalogSource {
|
|
26
32
|
constructor(
|
|
27
33
|
private readonly payload: Payload,
|
|
@@ -39,41 +45,69 @@ export class PayloadCatalogSource implements CatalogSource {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
async listProducts(query: ProductQuery = {}): Promise<Product[]> {
|
|
42
|
-
const where
|
|
48
|
+
const where: Record<string, unknown> = {};
|
|
49
|
+
if (query.category) {
|
|
50
|
+
where['categories.slug'] = { equals: query.category };
|
|
51
|
+
}
|
|
52
|
+
// Prefer DB-side title match when searching; applyProductQuery also matches description.
|
|
53
|
+
if (query.search?.trim() && !query.filters && query.priceMin == null && query.priceMax == null) {
|
|
54
|
+
where.title = { like: query.search.trim() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// When filtering by variant options/price or sorting by price, pull a wider page then refine.
|
|
58
|
+
const needsMemoryPass =
|
|
59
|
+
Boolean(query.filters && Object.keys(query.filters).length > 0) ||
|
|
60
|
+
query.priceMin != null ||
|
|
61
|
+
query.priceMax != null ||
|
|
62
|
+
query.sort === 'price-asc' ||
|
|
63
|
+
query.sort === 'price-desc' ||
|
|
64
|
+
query.sort === 'relevance';
|
|
65
|
+
|
|
66
|
+
const limit = needsMemoryPass ? Math.max(query.perPage ?? 24, 100) : (query.perPage ?? 24);
|
|
43
67
|
const res = await this.payload.find({
|
|
44
68
|
collection: 'products',
|
|
45
69
|
where,
|
|
46
70
|
depth: 1,
|
|
47
|
-
limit
|
|
48
|
-
page: query.page ?? 1,
|
|
71
|
+
limit,
|
|
72
|
+
page: needsMemoryPass ? 1 : (query.page ?? 1),
|
|
49
73
|
sort: sortExpr(query.sort),
|
|
74
|
+
...localeOpt(query.locale),
|
|
50
75
|
});
|
|
51
|
-
|
|
76
|
+
let products = await Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
|
|
77
|
+
products = applyProductQuery(products, query);
|
|
78
|
+
|
|
79
|
+
if (needsMemoryPass) {
|
|
80
|
+
const page = query.page ?? 1;
|
|
81
|
+
const perPage = query.perPage ?? 24;
|
|
82
|
+
const start = (page - 1) * perPage;
|
|
83
|
+
return products.slice(start, start + perPage);
|
|
84
|
+
}
|
|
85
|
+
return products;
|
|
52
86
|
}
|
|
53
87
|
|
|
54
|
-
async getProduct(slug: string): Promise<Product | null> {
|
|
88
|
+
async getProduct(slug: string, locale?: string): Promise<Product | null> {
|
|
55
89
|
const res = await this.payload.find({
|
|
56
90
|
collection: 'products',
|
|
57
91
|
where: { slug: { equals: slug } },
|
|
58
92
|
depth: 1,
|
|
59
93
|
limit: 1,
|
|
94
|
+
...localeOpt(locale),
|
|
60
95
|
});
|
|
61
96
|
const doc = res.docs[0];
|
|
62
97
|
return doc ? this.withVariants(doc as unknown as ProductDoc) : null;
|
|
63
98
|
}
|
|
64
99
|
|
|
65
|
-
async listCategories(): Promise<Category[]> {
|
|
66
|
-
const res = await this.payload.find({
|
|
100
|
+
async listCategories(locale?: string): Promise<Category[]> {
|
|
101
|
+
const res = await this.payload.find({
|
|
102
|
+
collection: 'categories',
|
|
103
|
+
depth: 0,
|
|
104
|
+
limit: 200,
|
|
105
|
+
...localeOpt(locale),
|
|
106
|
+
});
|
|
67
107
|
return res.docs.map((d) => mapCategory(d as unknown as never));
|
|
68
108
|
}
|
|
69
109
|
|
|
70
|
-
async search(term: string): Promise<Product[]> {
|
|
71
|
-
|
|
72
|
-
collection: 'products',
|
|
73
|
-
where: { title: { like: term } },
|
|
74
|
-
depth: 1,
|
|
75
|
-
limit: 24,
|
|
76
|
-
});
|
|
77
|
-
return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
|
|
110
|
+
async search(term: string, locale?: string): Promise<Product[]> {
|
|
111
|
+
return this.listProducts({ search: term, sort: 'relevance', perPage: 24, locale });
|
|
78
112
|
}
|
|
79
113
|
}
|
|
@@ -72,6 +72,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
|
|
|
72
72
|
id: variantId,
|
|
73
73
|
depth: 1,
|
|
74
74
|
})) as unknown as VariantDoc;
|
|
75
|
+
if (typeof variant.stock === 'number') {
|
|
76
|
+
if (variant.stock <= 0) throw new Error('[vitrine] variant is out of stock');
|
|
77
|
+
const alreadyInCart = cart.lines
|
|
78
|
+
.filter((l) => l.variantId === String(variant.id))
|
|
79
|
+
.reduce((sum, l) => sum + l.quantity, 0);
|
|
80
|
+
if (alreadyInCart + qty > variant.stock) {
|
|
81
|
+
throw new Error('[vitrine] not enough stock for this variant');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
75
84
|
const product = variant.product && typeof variant.product === 'object' ? (variant.product as ProductDoc) : null;
|
|
76
85
|
const firstImage =
|
|
77
86
|
product?.images && product.images.length > 0 && typeof product.images[0] === 'object'
|
|
@@ -120,6 +129,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
|
|
|
120
129
|
const current = await this.getCart(cartId);
|
|
121
130
|
if (!current || current.lines.length === 0) throw new Error('[vitrine] cart is empty');
|
|
122
131
|
const cart = await this.reprice(current);
|
|
132
|
+
for (const line of cart.lines) {
|
|
133
|
+
const variant = (await this.payload
|
|
134
|
+
.findByID({ collection: 'variants', id: line.variantId })
|
|
135
|
+
.catch(() => null)) as unknown as VariantDoc | null;
|
|
136
|
+
if (!variant) throw new Error('[vitrine] a cart line refers to a missing variant');
|
|
137
|
+
if (typeof variant.stock === 'number' && line.quantity > variant.stock) {
|
|
138
|
+
throw new Error('[vitrine] not enough stock for this variant');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
123
141
|
// The active provider (Stripe/Paddle/YooKassa) is resolved by integrations.payments;
|
|
124
142
|
// its checkout-<provider> feature registered it in the registry via lib/payments.ts.
|
|
125
143
|
return payments.resolve(this.siteConfig).createCheckout({ cart, baseUrl: this.baseUrl });
|