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