@vitrine-kit/vitrine 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +114 -44
- package/kit/registry/_index.json +70 -9
- package/kit/registry/accounts/docs/accounts.md +20 -0
- package/kit/registry/accounts/feature.json +55 -0
- package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
- package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
- package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
- package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
- package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
- package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
- package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
- package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
- package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
- package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
- package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
- package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
- package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
- package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/cart/files/app/api/cart/route.ts +101 -11
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
- package/kit/registry/cart/files/components/cart/CartView.tsx +21 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
- package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
- package/kit/registry/checkout/docs/checkout.md +19 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +25 -4
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
- package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
- package/kit/registry/checkout-paddle/feature.json +1 -1
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
- package/kit/registry/email/docs/email.md +19 -0
- package/kit/registry/email/feature.json +60 -0
- package/kit/registry/email/files/lib/email/adapter.ts +89 -0
- package/kit/registry/filters/docs/filters.md +13 -0
- package/kit/registry/filters/feature.json +45 -0
- package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
- package/kit/registry/filters/files/lib/filters/register.ts +6 -0
- package/kit/registry/i18n/docs/i18n.md +24 -0
- package/kit/registry/i18n/feature.json +47 -0
- package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
- package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
- package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
- package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
- package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
- package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/registry/i18n/files/middleware.ts +51 -0
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
- package/kit/registry/product-page/files/components/product/ProductView.tsx +8 -4
- package/kit/registry/reviews/docs/reviews.md +11 -0
- package/kit/registry/reviews/feature.json +55 -0
- package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
- package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
- package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
- package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
- package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
- package/kit/registry/search/docs/search.md +12 -0
- package/kit/registry/search/feature.json +49 -0
- package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
- package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
- package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
- package/kit/registry/search/files/lib/search/data.ts +8 -0
- package/kit/registry/search/files/lib/search/register.ts +8 -0
- package/kit/registry/seo/docs/seo.md +4 -1
- package/kit/registry/seo/feature.json +5 -1
- package/kit/registry/seo/files/app/robots.ts +13 -0
- package/kit/registry/seo/files/app/sitemap.ts +27 -0
- package/kit/registry/stubs/route-glue.d.ts +71 -0
- package/kit/registry/tsconfig.json +7 -2
- package/kit/registry/wishlist/docs/wishlist.md +11 -0
- package/kit/registry/wishlist/feature.json +55 -0
- package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
- package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
- package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
- package/kit/templates/backend-payload/files/Dockerfile +3 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
- package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +16 -6
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
- package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +92 -16
- package/kit/templates/backend-payload/files/next.config.mjs +34 -0
- package/kit/templates/backend-payload/files/payload.config.ts +33 -3
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
- package/kit/templates/backend-vendure/files/Dockerfile +1 -1
- package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
- package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
- package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
- package/kit/templates/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
- package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
- package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +84 -6
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
- package/kit/templates/base/files/app/globals.css +13 -0
- package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
- package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
- package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
- package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/templates/base/files/lib/site.ts +3 -0
- package/kit/templates/base/files/next.config.mjs +34 -0
- package/package.json +2 -2
|
@@ -0,0 +1,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,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.
|
|
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
|
+
}
|