@vitrine-kit/vitrine 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +114 -45
  3. package/kit/registry/_index.json +70 -9
  4. package/kit/registry/accounts/docs/accounts.md +20 -0
  5. package/kit/registry/accounts/feature.json +55 -0
  6. package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
  7. package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
  8. package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
  9. package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
  10. package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
  11. package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
  12. package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
  13. package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
  14. package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
  15. package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
  16. package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
  17. package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
  18. package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
  19. package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
  20. package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
  21. package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
  22. package/kit/registry/cart/feature.json +1 -1
  23. package/kit/registry/cart/files/app/api/cart/route.ts +46 -8
  24. package/kit/registry/cart/files/components/cart/AddToCart.tsx +38 -3
  25. package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
  26. package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
  27. package/kit/registry/cart/files/components/cart/CartView.tsx +9 -7
  28. package/kit/registry/catalog/feature.json +1 -1
  29. package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
  30. package/kit/registry/checkout/docs/checkout.md +19 -1
  31. package/kit/registry/checkout/feature.json +1 -1
  32. package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
  33. package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -3
  34. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
  35. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
  36. package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
  37. package/kit/registry/checkout-paddle/feature.json +1 -1
  38. package/kit/registry/checkout-stripe/feature.json +1 -1
  39. package/kit/registry/checkout-yookassa/feature.json +1 -1
  40. package/kit/registry/email/docs/email.md +19 -0
  41. package/kit/registry/email/feature.json +60 -0
  42. package/kit/registry/email/files/lib/email/adapter.ts +89 -0
  43. package/kit/registry/filters/docs/filters.md +13 -0
  44. package/kit/registry/filters/feature.json +45 -0
  45. package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
  46. package/kit/registry/filters/files/lib/filters/register.ts +6 -0
  47. package/kit/registry/i18n/docs/i18n.md +24 -0
  48. package/kit/registry/i18n/feature.json +47 -0
  49. package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
  50. package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
  51. package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
  52. package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
  53. package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
  54. package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
  55. package/kit/registry/i18n/files/middleware.ts +51 -0
  56. package/kit/registry/product-page/feature.json +1 -1
  57. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
  58. package/kit/registry/product-page/files/components/product/ProductView.tsx +3 -3
  59. package/kit/registry/reviews/docs/reviews.md +11 -0
  60. package/kit/registry/reviews/feature.json +55 -0
  61. package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
  62. package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
  63. package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
  64. package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
  65. package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
  66. package/kit/registry/search/docs/search.md +12 -0
  67. package/kit/registry/search/feature.json +49 -0
  68. package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
  69. package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
  70. package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
  71. package/kit/registry/search/files/lib/search/data.ts +8 -0
  72. package/kit/registry/search/files/lib/search/register.ts +8 -0
  73. package/kit/registry/seo/docs/seo.md +4 -1
  74. package/kit/registry/seo/feature.json +5 -1
  75. package/kit/registry/seo/files/app/robots.ts +13 -0
  76. package/kit/registry/seo/files/app/sitemap.ts +27 -0
  77. package/kit/registry/stubs/route-glue.d.ts +71 -0
  78. package/kit/registry/tsconfig.json +7 -2
  79. package/kit/registry/wishlist/docs/wishlist.md +11 -0
  80. package/kit/registry/wishlist/feature.json +55 -0
  81. package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
  82. package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
  83. package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
  84. package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
  85. package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
  86. package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
  87. package/kit/templates/backend-payload/files/Dockerfile +3 -0
  88. package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
  89. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
  90. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
  91. package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
  92. package/kit/templates/backend-payload/files/lib/seed/admin.ts +7 -3
  93. package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
  94. package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
  95. package/kit/templates/backend-payload/files/lib/seed/run.ts +57 -6
  96. package/kit/templates/backend-payload/files/next.config.mjs +8 -2
  97. package/kit/templates/backend-payload/files/payload.config.ts +27 -3
  98. package/kit/templates/backend-vendure/files/Dockerfile +1 -1
  99. package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
  100. package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
  101. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
  102. package/kit/templates/base/files/.claude/commands/update.md +1 -1
  103. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +51 -4
  104. package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
  105. package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
  106. package/kit/templates/base/files/app/(frontend)/page.tsx +49 -4
  107. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
  108. package/kit/templates/base/files/app/globals.css +13 -0
  109. package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
  110. package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
  111. package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
  112. package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
  113. package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
  114. package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
  115. package/kit/templates/base/files/lib/site.ts +3 -0
  116. package/kit/templates/base/files/next.config.mjs +8 -2
  117. package/package.json +2 -2
@@ -0,0 +1,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.4.3",
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: // newest and unspecified sort
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 = query.category ? { 'categories.slug': { equals: query.category } } : {};
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: query.perPage ?? 24,
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
- return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
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({ collection: 'categories', depth: 0, limit: 200 });
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
- const res = await this.payload.find({
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 });