@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.
Files changed (125) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +114 -44
  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 +101 -11
  24. package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
  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 +21 -1
  28. package/kit/registry/catalog/feature.json +1 -1
  29. package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
  30. package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
  31. package/kit/registry/checkout/docs/checkout.md +19 -1
  32. package/kit/registry/checkout/feature.json +1 -1
  33. package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
  34. package/kit/registry/checkout/files/app/api/checkout/route.ts +25 -4
  35. package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
  36. package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
  37. package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
  38. package/kit/registry/checkout-paddle/feature.json +1 -1
  39. package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
  40. package/kit/registry/checkout-stripe/feature.json +1 -1
  41. package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
  42. package/kit/registry/checkout-yookassa/feature.json +1 -1
  43. package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
  44. package/kit/registry/email/docs/email.md +19 -0
  45. package/kit/registry/email/feature.json +60 -0
  46. package/kit/registry/email/files/lib/email/adapter.ts +89 -0
  47. package/kit/registry/filters/docs/filters.md +13 -0
  48. package/kit/registry/filters/feature.json +45 -0
  49. package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
  50. package/kit/registry/filters/files/lib/filters/register.ts +6 -0
  51. package/kit/registry/i18n/docs/i18n.md +24 -0
  52. package/kit/registry/i18n/feature.json +47 -0
  53. package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
  54. package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
  55. package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
  56. package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
  57. package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
  58. package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
  59. package/kit/registry/i18n/files/middleware.ts +51 -0
  60. package/kit/registry/product-page/feature.json +1 -1
  61. package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
  62. package/kit/registry/product-page/files/components/product/ProductView.tsx +8 -4
  63. package/kit/registry/reviews/docs/reviews.md +11 -0
  64. package/kit/registry/reviews/feature.json +55 -0
  65. package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
  66. package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
  67. package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
  68. package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
  69. package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
  70. package/kit/registry/search/docs/search.md +12 -0
  71. package/kit/registry/search/feature.json +49 -0
  72. package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
  73. package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
  74. package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
  75. package/kit/registry/search/files/lib/search/data.ts +8 -0
  76. package/kit/registry/search/files/lib/search/register.ts +8 -0
  77. package/kit/registry/seo/docs/seo.md +4 -1
  78. package/kit/registry/seo/feature.json +5 -1
  79. package/kit/registry/seo/files/app/robots.ts +13 -0
  80. package/kit/registry/seo/files/app/sitemap.ts +27 -0
  81. package/kit/registry/stubs/route-glue.d.ts +71 -0
  82. package/kit/registry/tsconfig.json +7 -2
  83. package/kit/registry/wishlist/docs/wishlist.md +11 -0
  84. package/kit/registry/wishlist/feature.json +55 -0
  85. package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
  86. package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
  87. package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
  88. package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
  89. package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
  90. package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
  91. package/kit/templates/backend-payload/files/Dockerfile +3 -0
  92. package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
  93. package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
  94. package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
  95. package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
  96. package/kit/templates/backend-payload/files/lib/seed/admin.ts +16 -6
  97. package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
  98. package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
  99. package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
  100. package/kit/templates/backend-payload/files/lib/seed/run.ts +92 -16
  101. package/kit/templates/backend-payload/files/next.config.mjs +34 -0
  102. package/kit/templates/backend-payload/files/payload.config.ts +33 -3
  103. package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
  104. package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
  105. package/kit/templates/backend-vendure/files/Dockerfile +1 -1
  106. package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
  107. package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
  108. package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
  109. package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
  110. package/kit/templates/base/files/.claude/commands/update.md +1 -1
  111. package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
  112. package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
  113. package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
  114. package/kit/templates/base/files/app/(frontend)/page.tsx +84 -6
  115. package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
  116. package/kit/templates/base/files/app/globals.css +13 -0
  117. package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
  118. package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
  119. package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
  120. package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
  121. package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
  122. package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
  123. package/kit/templates/base/files/lib/site.ts +3 -0
  124. package/kit/templates/base/files/next.config.mjs +34 -0
  125. package/package.json +2 -2
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+ import { useMemo, useState, type FormEvent } from 'react';
3
+ import type { Product } from '@vitrine-kit/contracts';
4
+ import { parseReviews, type ProductReview } from '../../lib/reviews/types.js';
5
+
6
+ export interface ReviewListProps {
7
+ product?: Product;
8
+ }
9
+
10
+ export function ReviewList({ product }: ReviewListProps) {
11
+ const initial = useMemo(
12
+ () => parseReviews(product?.extensions?.reviews),
13
+ [product?.extensions?.reviews],
14
+ );
15
+ const [reviews, setReviews] = useState<ProductReview[]>(initial);
16
+ const [author, setAuthor] = useState('');
17
+ const [rating, setRating] = useState(5);
18
+ const [body, setBody] = useState('');
19
+ const [pending, setPending] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ if (!product) return null;
23
+
24
+ async function submit(e: FormEvent): Promise<void> {
25
+ e.preventDefault();
26
+ setPending(true);
27
+ setError(null);
28
+ try {
29
+ const res = await fetch('/api/reviews', {
30
+ method: 'POST',
31
+ headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({ productId: product!.id, author, rating, body }),
33
+ });
34
+ const data = (await res.json().catch(() => ({}))) as { review?: ProductReview; error?: string };
35
+ if (!res.ok || !data.review) {
36
+ setError(data.error ?? 'Could not submit review.');
37
+ return;
38
+ }
39
+ setReviews((prev) => [data.review!, ...prev]);
40
+ setAuthor('');
41
+ setBody('');
42
+ setRating(5);
43
+ } catch {
44
+ setError('Could not submit review.');
45
+ } finally {
46
+ setPending(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <section className="vt-reviews flex flex-col gap-gutter border-t border-border pt-section" aria-labelledby="reviews-heading">
52
+ <h2 id="reviews-heading" className="font-heading text-fg">
53
+ Reviews
54
+ </h2>
55
+ {reviews.length === 0 ? (
56
+ <p className="text-muted-fg">No reviews yet — be the first.</p>
57
+ ) : (
58
+ <ul role="list" className="flex flex-col gap-gutter">
59
+ {reviews.map((r) => (
60
+ <li key={r.id} className="flex flex-col gap-unit border-b border-border pb-unit">
61
+ <p className="text-fg">
62
+ <span className="font-medium">{r.author}</span>
63
+ <span className="text-muted-fg"> — {r.rating}/5</span>
64
+ </p>
65
+ <p className="text-fg">{r.body}</p>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ )}
70
+ <form onSubmit={submit} className="flex max-w-md flex-col gap-gutter">
71
+ <label className="flex flex-col gap-unit text-sm">
72
+ <span className="text-fg">Name</span>
73
+ <input
74
+ required
75
+ value={author}
76
+ onChange={(e) => setAuthor(e.target.value)}
77
+ 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"
78
+ />
79
+ </label>
80
+ <label className="flex flex-col gap-unit text-sm">
81
+ <span className="text-fg">Rating</span>
82
+ <select
83
+ value={rating}
84
+ onChange={(e) => setRating(Number(e.target.value))}
85
+ 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"
86
+ >
87
+ {[5, 4, 3, 2, 1].map((n) => (
88
+ <option key={n} value={n}>
89
+ {n}
90
+ </option>
91
+ ))}
92
+ </select>
93
+ </label>
94
+ <label className="flex flex-col gap-unit text-sm">
95
+ <span className="text-fg">Review</span>
96
+ <textarea
97
+ required
98
+ rows={3}
99
+ value={body}
100
+ onChange={(e) => setBody(e.target.value)}
101
+ 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"
102
+ />
103
+ </label>
104
+ <button
105
+ type="submit"
106
+ disabled={pending}
107
+ className="w-fit rounded-md bg-primary px-gutter py-unit text-primary-fg disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
108
+ >
109
+ {pending ? 'Submitting…' : 'Submit review'}
110
+ </button>
111
+ {error ? (
112
+ <p role="alert" className="text-danger">
113
+ {error}
114
+ </p>
115
+ ) : null}
116
+ </form>
117
+ </section>
118
+ );
119
+ }
@@ -0,0 +1,13 @@
1
+ import type { Extend } from '@vitrine-kit/contracts';
2
+
3
+ export function extendReviewsBlueprint(blueprint: { extend: Extend }): void {
4
+ blueprint.extend('product', {
5
+ addFields: [
6
+ {
7
+ name: 'reviews',
8
+ type: 'json',
9
+ label: 'Reviews',
10
+ },
11
+ ],
12
+ });
13
+ }
@@ -0,0 +1,6 @@
1
+ import { registerSlot } from '@vitrine-kit/core';
2
+ import { ReviewList } from '../../components/reviews/ReviewList.js';
3
+
4
+ export function registerReviewsSlots(): void {
5
+ registerSlot({ slot: 'product.below-description', component: ReviewList, order: 20 });
6
+ }
@@ -0,0 +1,22 @@
1
+ export interface ProductReview {
2
+ id: string;
3
+ author: string;
4
+ rating: number;
5
+ body: string;
6
+ createdAt: string;
7
+ }
8
+
9
+ export function parseReviews(value: unknown): ProductReview[] {
10
+ if (!Array.isArray(value)) return [];
11
+ return value.filter((r): r is ProductReview => {
12
+ if (!r || typeof r !== 'object') return false;
13
+ const o = r as Record<string, unknown>;
14
+ return (
15
+ typeof o.id === 'string' &&
16
+ typeof o.author === 'string' &&
17
+ typeof o.rating === 'number' &&
18
+ typeof o.body === 'string' &&
19
+ typeof o.createdAt === 'string'
20
+ );
21
+ });
22
+ }
@@ -0,0 +1,12 @@
1
+ # Feature: search
2
+
3
+ Header search form and `/search` results page over the `CatalogSource` contract.
4
+
5
+ ## Modules
6
+ - `components/search/SearchForm` — GET form → `/search?q=` (slot `global.header-actions`)
7
+ - `components/search/SearchResults` — results grid + empty state (hosts `search.*` slots)
8
+ - `lib/search/data` — `loadSearch(source, term)` via `CatalogSource.search`
9
+ - `app/(frontend)/search/page.tsx` — results page
10
+
11
+ ## Contracts
12
+ `@vitrine-kit/contracts` (`CatalogSource.search`) + `@vitrine-kit/core` slots.
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "../../schemas/feature.schema.json",
3
+ "name": "search",
4
+ "title": "Product search",
5
+ "kitVersion": "0.5.0",
6
+ "requiresContracts": ">=1.0.0 <2.0.0",
7
+ "tier": [
8
+ "catalog",
9
+ "simple-store",
10
+ "full-store"
11
+ ],
12
+ "registryDependencies": [
13
+ "catalog"
14
+ ],
15
+ "corePackages": {
16
+ "@vitrine-kit/core": ">=0.1.0"
17
+ },
18
+ "npm": [],
19
+ "files": [
20
+ {
21
+ "from": "files/components/search/",
22
+ "to": "components/search/"
23
+ },
24
+ {
25
+ "from": "files/lib/search/",
26
+ "to": "lib/search/"
27
+ },
28
+ {
29
+ "from": "files/app/",
30
+ "to": "app/"
31
+ }
32
+ ],
33
+ "config": {
34
+ "set": {
35
+ "features.search": true
36
+ }
37
+ },
38
+ "slots": [
39
+ {
40
+ "slot": "global.header-actions",
41
+ "component": "SearchForm",
42
+ "order": 5
43
+ }
44
+ ],
45
+ "env": [],
46
+ "claudeDoc": "docs/search.md",
47
+ "conflicts": [],
48
+ "removable": true
49
+ }
@@ -0,0 +1,34 @@
1
+ // Search results page. Query string `q` → CatalogSource.search.
2
+ import type { Metadata } from 'next';
3
+ import { getCatalogSource } from '@/lib/adapter';
4
+ import { loadSearch } from '@/lib/search/data';
5
+ import { SearchResults } from '@/components/search/SearchResults';
6
+
7
+ interface PageProps {
8
+ searchParams: Promise<{ q?: string }>;
9
+ }
10
+
11
+ export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
12
+ const { q } = await searchParams;
13
+ const term = q?.trim() ?? '';
14
+ return {
15
+ title: term ? `Search: ${term}` : 'Search',
16
+ robots: { index: false, follow: true },
17
+ };
18
+ }
19
+
20
+ export default async function SearchPage({ searchParams }: PageProps) {
21
+ const { q } = await searchParams;
22
+ const term = q?.trim() ?? '';
23
+ const source = await getCatalogSource();
24
+ const products = term ? await loadSearch(source, term) : [];
25
+
26
+ return (
27
+ <div className="flex flex-col gap-section">
28
+ <header className="flex flex-col gap-unit">
29
+ <h1 className="font-heading text-fg">Search</h1>
30
+ </header>
31
+ <SearchResults term={term} products={products} />
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,32 @@
1
+ // Header search form — GET /search?q= so results are shareable and crawlable.
2
+ 'use client';
3
+
4
+ import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
5
+
6
+ export function SearchForm() {
7
+ const search = useChromeLabel('search');
8
+
9
+ return (
10
+ <search className="vt-search-form">
11
+ <form action="/search" method="get" className="flex items-center gap-unit">
12
+ <label className="sr-only" htmlFor="vt-search-q">
13
+ {search} products
14
+ </label>
15
+ <input
16
+ id="vt-search-q"
17
+ type="search"
18
+ name="q"
19
+ placeholder={search}
20
+ autoComplete="off"
21
+ className="w-28 rounded-md border border-input bg-surface px-unit py-unit text-sm text-surface-fg md:w-40 focus-visible:outline-none focus-visible:ring-2 ring-ring"
22
+ />
23
+ <button
24
+ type="submit"
25
+ className="rounded-md border border-border px-unit py-unit text-sm text-fg transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 ring-ring"
26
+ >
27
+ {search}
28
+ </button>
29
+ </form>
30
+ </search>
31
+ );
32
+ }
@@ -0,0 +1,87 @@
1
+ // Search results — presentational. Hosts search.results-top / search.empty slots.
2
+ // Inline cards (not ProductGrid) so the feature typechecks without cross-feature imports.
3
+ import type { Product } from '@vitrine-kit/contracts';
4
+ import { Slot } from '@vitrine-kit/core/react';
5
+
6
+ export interface SearchResultsProps {
7
+ term: string;
8
+ products: Product[];
9
+ }
10
+
11
+ function formatPrice(amount: number, currency: string): string {
12
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount / 100);
13
+ }
14
+
15
+ export function SearchResults({ term, products }: SearchResultsProps) {
16
+ if (!term) {
17
+ return (
18
+ <p className="text-muted-fg">Type a query in the header search to find products.</p>
19
+ );
20
+ }
21
+
22
+ if (products.length === 0) {
23
+ return (
24
+ <div className="vt-search-empty flex flex-col gap-gutter">
25
+ <Slot name="search.empty" term={term} />
26
+ <p className="text-muted-fg">
27
+ No products matched <span className="text-fg">“{term}”</span>.
28
+ </p>
29
+ <a
30
+ href="/"
31
+ className="w-fit rounded-md border border-border px-gutter py-unit text-fg transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 ring-ring"
32
+ >
33
+ Browse catalog
34
+ </a>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <div className="vt-search-results flex flex-col gap-gutter">
41
+ <Slot name="search.results-top" term={term} products={products} />
42
+ <p className="text-sm text-muted-fg">
43
+ {products.length} result{products.length === 1 ? '' : 's'} for “{term}”
44
+ </p>
45
+ <ul role="list" className="grid grid-cols-1 gap-gutter sm:grid-cols-2 lg:grid-cols-3">
46
+ {products.map((product) => {
47
+ const price = product.priceRange?.min ?? product.variants[0]?.price;
48
+ const currency = product.priceRange?.currency ?? product.variants[0]?.currency ?? 'USD';
49
+ const image = product.images[0];
50
+ return (
51
+ <li key={product.id}>
52
+ <article className="vt-product-card rounded-md border border-border bg-surface text-surface-fg">
53
+ <a
54
+ href={`/products/${product.slug}`}
55
+ className="block focus-visible:outline-none focus-visible:ring-2 ring-ring"
56
+ >
57
+ {image ? (
58
+ <img
59
+ src={image.url}
60
+ alt={image.alt ?? product.title}
61
+ width={image.width}
62
+ height={image.height}
63
+ loading="lazy"
64
+ className="aspect-square w-full rounded-t-md object-cover"
65
+ />
66
+ ) : (
67
+ <div className="aspect-square w-full rounded-t-md bg-muted" aria-hidden="true" />
68
+ )}
69
+ <div className="p-gutter">
70
+ <h3 className="font-heading text-fg">{product.title}</h3>
71
+ {price != null ? (
72
+ <p className="text-price">
73
+ {product.priceRange && product.priceRange.min !== product.priceRange.max
74
+ ? `From ${formatPrice(price, currency)}`
75
+ : formatPrice(price, currency)}
76
+ </p>
77
+ ) : null}
78
+ </div>
79
+ </a>
80
+ </article>
81
+ </li>
82
+ );
83
+ })}
84
+ </ul>
85
+ </div>
86
+ );
87
+ }
@@ -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.1",
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
+ }