@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,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
4
|
+
import { isWishlisted, toggleWishlist } from '../../lib/wishlist/storage.js';
|
|
5
|
+
|
|
6
|
+
export interface WishlistButtonProps {
|
|
7
|
+
product?: Product;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function WishlistButton({ product }: WishlistButtonProps) {
|
|
11
|
+
const slug = product?.slug;
|
|
12
|
+
const [on, setOn] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!slug) return;
|
|
16
|
+
setOn(isWishlisted(slug));
|
|
17
|
+
const sync = () => setOn(isWishlisted(slug));
|
|
18
|
+
window.addEventListener('vitrine:wishlist', sync);
|
|
19
|
+
window.addEventListener('storage', sync);
|
|
20
|
+
return () => {
|
|
21
|
+
window.removeEventListener('vitrine:wishlist', sync);
|
|
22
|
+
window.removeEventListener('storage', sync);
|
|
23
|
+
};
|
|
24
|
+
}, [slug]);
|
|
25
|
+
|
|
26
|
+
if (!slug) return null;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className="vt-wishlist-button w-fit text-sm text-muted-fg underline underline-offset-2 transition hover:text-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
32
|
+
onClick={() => setOn(toggleWishlist(slug).includes(slug))}
|
|
33
|
+
>
|
|
34
|
+
{on ? 'Remove from wishlist' : 'Add to wishlist'}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { readWishlist } from '../../lib/wishlist/storage.js';
|
|
4
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
5
|
+
|
|
6
|
+
export function WishlistIndicator() {
|
|
7
|
+
const [count, setCount] = useState(0);
|
|
8
|
+
const label = useChromeLabel('wishlist');
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const sync = () => setCount(readWishlist().length);
|
|
12
|
+
sync();
|
|
13
|
+
window.addEventListener('vitrine:wishlist', sync);
|
|
14
|
+
window.addEventListener('storage', sync);
|
|
15
|
+
return () => {
|
|
16
|
+
window.removeEventListener('vitrine:wishlist', sync);
|
|
17
|
+
window.removeEventListener('storage', sync);
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<a
|
|
23
|
+
href="/wishlist"
|
|
24
|
+
className="vt-wishlist-indicator text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
25
|
+
>
|
|
26
|
+
{label}
|
|
27
|
+
{count > 0 ? ` (${count})` : ''}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { readWishlist, writeWishlist } from '../../lib/wishlist/storage.js';
|
|
4
|
+
|
|
5
|
+
interface WishItem {
|
|
6
|
+
slug: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function WishlistView() {
|
|
11
|
+
const [items, setItems] = useState<WishItem[]>([]);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const slugs = readWishlist();
|
|
16
|
+
if (slugs.length === 0) {
|
|
17
|
+
setItems([]);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
Promise.all(
|
|
22
|
+
slugs.map(async (slug) => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`/api/products?where[slug][equals]=${encodeURIComponent(slug)}&limit=1`);
|
|
25
|
+
if (!res.ok) return { slug };
|
|
26
|
+
const data = (await res.json()) as { docs?: Array<{ title?: string }> };
|
|
27
|
+
return { slug, title: data.docs?.[0]?.title };
|
|
28
|
+
} catch {
|
|
29
|
+
return { slug };
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
).then((list) => {
|
|
33
|
+
setItems(list);
|
|
34
|
+
setLoading(false);
|
|
35
|
+
});
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
if (loading) return <p className="text-muted-fg">Loading wishlist…</p>;
|
|
39
|
+
|
|
40
|
+
if (items.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col gap-gutter">
|
|
43
|
+
<p className="text-muted-fg">Your wishlist is empty.</p>
|
|
44
|
+
<a href="/" className="w-fit text-fg underline underline-offset-2">
|
|
45
|
+
Browse catalog
|
|
46
|
+
</a>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ul role="list" className="flex flex-col gap-gutter">
|
|
53
|
+
{items.map((item) => (
|
|
54
|
+
<li key={item.slug} className="flex items-center justify-between gap-gutter border-b border-border py-unit">
|
|
55
|
+
<a href={`/products/${item.slug}`} className="text-fg underline-offset-2 hover:underline">
|
|
56
|
+
{item.title ?? item.slug}
|
|
57
|
+
</a>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="text-sm text-muted-fg underline underline-offset-2 hover:text-fg"
|
|
61
|
+
onClick={() => {
|
|
62
|
+
const next = readWishlist().filter((s) => s !== item.slug);
|
|
63
|
+
writeWishlist(next);
|
|
64
|
+
setItems((prev) => prev.filter((p) => p.slug !== item.slug));
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
Remove
|
|
68
|
+
</button>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerSlot } from '@vitrine-kit/core';
|
|
2
|
+
import { WishlistButton } from '../../components/wishlist/WishlistButton.js';
|
|
3
|
+
import { WishlistIndicator } from '../../components/wishlist/WishlistIndicator.js';
|
|
4
|
+
|
|
5
|
+
export function registerWishlistSlots(): void {
|
|
6
|
+
registerSlot({ slot: 'product.below-price', component: WishlistButton, order: 20 });
|
|
7
|
+
registerSlot({ slot: 'global.header-actions', component: WishlistIndicator, order: 15 });
|
|
8
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// localStorage wishlist of product slugs. Pure browser helper — no server.
|
|
2
|
+
const KEY = 'vitrine_wishlist';
|
|
3
|
+
|
|
4
|
+
export function readWishlist(): string[] {
|
|
5
|
+
if (typeof window === 'undefined') return [];
|
|
6
|
+
try {
|
|
7
|
+
const raw = window.localStorage.getItem(KEY);
|
|
8
|
+
if (!raw) return [];
|
|
9
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
10
|
+
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeWishlist(slugs: string[]): void {
|
|
17
|
+
window.localStorage.setItem(KEY, JSON.stringify([...new Set(slugs)]));
|
|
18
|
+
window.dispatchEvent(new Event('vitrine:wishlist'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleWishlist(slug: string): string[] {
|
|
22
|
+
const current = readWishlist();
|
|
23
|
+
const next = current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug];
|
|
24
|
+
writeWishlist(next);
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isWishlisted(slug: string): boolean {
|
|
29
|
+
return readWishlist().includes(slug);
|
|
30
|
+
}
|
|
@@ -25,5 +25,8 @@ WORKDIR /app
|
|
|
25
25
|
COPY --from=build /app/public ./public
|
|
26
26
|
COPY --from=build /app/.next/standalone ./
|
|
27
27
|
COPY --from=build /app/.next/static ./.next/static
|
|
28
|
+
# Demo seed + uploads (SEED_ON_BOOT); media is also a named volume in compose.
|
|
29
|
+
COPY --from=build /app/seed-assets ./seed-assets
|
|
30
|
+
RUN mkdir -p /app/media
|
|
28
31
|
EXPOSE 3000
|
|
29
32
|
CMD ["node", "server.js"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Reference Vitrine deployment on a VPS: app (Next+Payload) + Postgres.
|
|
2
2
|
# Run: PAYLOAD_SECRET=... docker compose up --build
|
|
3
|
+
# First boot seeds demo catalog + admin when SEED_ON_BOOT=1 (default here).
|
|
3
4
|
services:
|
|
4
5
|
db:
|
|
5
6
|
image: postgres:16-alpine
|
|
@@ -25,11 +26,18 @@ services:
|
|
|
25
26
|
DATABASE_URL: postgres://vitrine:${POSTGRES_PASSWORD:-vitrine}@db:5432/vitrine
|
|
26
27
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:?set PAYLOAD_SECRET}
|
|
27
28
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
|
|
29
|
+
SEED_ON_BOOT: ${SEED_ON_BOOT:-1}
|
|
30
|
+
DEV_ADMIN_EMAIL: ${DEV_ADMIN_EMAIL:-admin@example.com}
|
|
31
|
+
DEV_ADMIN_PASSWORD: ${DEV_ADMIN_PASSWORD:-}
|
|
32
|
+
EMAIL_FROM: ${EMAIL_FROM:-noreply@localhost}
|
|
28
33
|
ports:
|
|
29
34
|
- '3000:3000'
|
|
35
|
+
volumes:
|
|
36
|
+
- media:/app/media
|
|
30
37
|
depends_on:
|
|
31
38
|
db:
|
|
32
39
|
condition: service_healthy
|
|
33
40
|
|
|
34
41
|
volumes:
|
|
35
42
|
pgdata:
|
|
43
|
+
media:
|
|
@@ -9,19 +9,25 @@ import type {
|
|
|
9
9
|
ProductSort,
|
|
10
10
|
} from '@vitrine-kit/contracts';
|
|
11
11
|
import { mapCategory, mapProduct } from './map.js';
|
|
12
|
+
import { applyProductQuery } from './product-query.js';
|
|
12
13
|
import type { ProductDoc, VariantDoc } from './payload-types.js';
|
|
13
14
|
|
|
14
15
|
function sortExpr(sort?: ProductSort): string {
|
|
16
|
+
// Price sorts are applied in memory after variants load (see applyProductQuery).
|
|
15
17
|
switch (sort) {
|
|
16
18
|
case 'price-asc':
|
|
17
|
-
return 'createdAt'; // price lives in the variants collection — price sorting is provided by the filters feature
|
|
18
19
|
case 'price-desc':
|
|
20
|
+
case 'relevance':
|
|
19
21
|
return '-createdAt';
|
|
20
|
-
default:
|
|
22
|
+
default:
|
|
21
23
|
return '-createdAt';
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function localeOpt(locale?: string): { locale?: string } {
|
|
28
|
+
return locale ? { locale } : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export class PayloadCatalogSource implements CatalogSource {
|
|
26
32
|
constructor(
|
|
27
33
|
private readonly payload: Payload,
|
|
@@ -39,41 +45,69 @@ export class PayloadCatalogSource implements CatalogSource {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
async listProducts(query: ProductQuery = {}): Promise<Product[]> {
|
|
42
|
-
const where
|
|
48
|
+
const where: Record<string, unknown> = {};
|
|
49
|
+
if (query.category) {
|
|
50
|
+
where['categories.slug'] = { equals: query.category };
|
|
51
|
+
}
|
|
52
|
+
// Prefer DB-side title match when searching; applyProductQuery also matches description.
|
|
53
|
+
if (query.search?.trim() && !query.filters && query.priceMin == null && query.priceMax == null) {
|
|
54
|
+
where.title = { like: query.search.trim() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// When filtering by variant options/price or sorting by price, pull a wider page then refine.
|
|
58
|
+
const needsMemoryPass =
|
|
59
|
+
Boolean(query.filters && Object.keys(query.filters).length > 0) ||
|
|
60
|
+
query.priceMin != null ||
|
|
61
|
+
query.priceMax != null ||
|
|
62
|
+
query.sort === 'price-asc' ||
|
|
63
|
+
query.sort === 'price-desc' ||
|
|
64
|
+
query.sort === 'relevance';
|
|
65
|
+
|
|
66
|
+
const limit = needsMemoryPass ? Math.max(query.perPage ?? 24, 100) : (query.perPage ?? 24);
|
|
43
67
|
const res = await this.payload.find({
|
|
44
68
|
collection: 'products',
|
|
45
69
|
where,
|
|
46
70
|
depth: 1,
|
|
47
|
-
limit
|
|
48
|
-
page: query.page ?? 1,
|
|
71
|
+
limit,
|
|
72
|
+
page: needsMemoryPass ? 1 : (query.page ?? 1),
|
|
49
73
|
sort: sortExpr(query.sort),
|
|
74
|
+
...localeOpt(query.locale),
|
|
50
75
|
});
|
|
51
|
-
|
|
76
|
+
let products = await Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
|
|
77
|
+
products = applyProductQuery(products, query);
|
|
78
|
+
|
|
79
|
+
if (needsMemoryPass) {
|
|
80
|
+
const page = query.page ?? 1;
|
|
81
|
+
const perPage = query.perPage ?? 24;
|
|
82
|
+
const start = (page - 1) * perPage;
|
|
83
|
+
return products.slice(start, start + perPage);
|
|
84
|
+
}
|
|
85
|
+
return products;
|
|
52
86
|
}
|
|
53
87
|
|
|
54
|
-
async getProduct(slug: string): Promise<Product | null> {
|
|
88
|
+
async getProduct(slug: string, locale?: string): Promise<Product | null> {
|
|
55
89
|
const res = await this.payload.find({
|
|
56
90
|
collection: 'products',
|
|
57
91
|
where: { slug: { equals: slug } },
|
|
58
92
|
depth: 1,
|
|
59
93
|
limit: 1,
|
|
94
|
+
...localeOpt(locale),
|
|
60
95
|
});
|
|
61
96
|
const doc = res.docs[0];
|
|
62
97
|
return doc ? this.withVariants(doc as unknown as ProductDoc) : null;
|
|
63
98
|
}
|
|
64
99
|
|
|
65
|
-
async listCategories(): Promise<Category[]> {
|
|
66
|
-
const res = await this.payload.find({
|
|
100
|
+
async listCategories(locale?: string): Promise<Category[]> {
|
|
101
|
+
const res = await this.payload.find({
|
|
102
|
+
collection: 'categories',
|
|
103
|
+
depth: 0,
|
|
104
|
+
limit: 200,
|
|
105
|
+
...localeOpt(locale),
|
|
106
|
+
});
|
|
67
107
|
return res.docs.map((d) => mapCategory(d as unknown as never));
|
|
68
108
|
}
|
|
69
109
|
|
|
70
|
-
async search(term: string): Promise<Product[]> {
|
|
71
|
-
|
|
72
|
-
collection: 'products',
|
|
73
|
-
where: { title: { like: term } },
|
|
74
|
-
depth: 1,
|
|
75
|
-
limit: 24,
|
|
76
|
-
});
|
|
77
|
-
return Promise.all(res.docs.map((d) => this.withVariants(d as unknown as ProductDoc)));
|
|
110
|
+
async search(term: string, locale?: string): Promise<Product[]> {
|
|
111
|
+
return this.listProducts({ search: term, sort: 'relevance', perPage: 24, locale });
|
|
78
112
|
}
|
|
79
113
|
}
|
|
@@ -72,6 +72,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
|
|
|
72
72
|
id: variantId,
|
|
73
73
|
depth: 1,
|
|
74
74
|
})) as unknown as VariantDoc;
|
|
75
|
+
if (typeof variant.stock === 'number') {
|
|
76
|
+
if (variant.stock <= 0) throw new Error('[vitrine] variant is out of stock');
|
|
77
|
+
const alreadyInCart = cart.lines
|
|
78
|
+
.filter((l) => l.variantId === String(variant.id))
|
|
79
|
+
.reduce((sum, l) => sum + l.quantity, 0);
|
|
80
|
+
if (alreadyInCart + qty > variant.stock) {
|
|
81
|
+
throw new Error('[vitrine] not enough stock for this variant');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
75
84
|
const product = variant.product && typeof variant.product === 'object' ? (variant.product as ProductDoc) : null;
|
|
76
85
|
const firstImage =
|
|
77
86
|
product?.images && product.images.length > 0 && typeof product.images[0] === 'object'
|
|
@@ -120,6 +129,15 @@ export class PayloadCommerceBackend implements CommerceBackend {
|
|
|
120
129
|
const current = await this.getCart(cartId);
|
|
121
130
|
if (!current || current.lines.length === 0) throw new Error('[vitrine] cart is empty');
|
|
122
131
|
const cart = await this.reprice(current);
|
|
132
|
+
for (const line of cart.lines) {
|
|
133
|
+
const variant = (await this.payload
|
|
134
|
+
.findByID({ collection: 'variants', id: line.variantId })
|
|
135
|
+
.catch(() => null)) as unknown as VariantDoc | null;
|
|
136
|
+
if (!variant) throw new Error('[vitrine] a cart line refers to a missing variant');
|
|
137
|
+
if (typeof variant.stock === 'number' && line.quantity > variant.stock) {
|
|
138
|
+
throw new Error('[vitrine] not enough stock for this variant');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
123
141
|
// The active provider (Stripe/Paddle/YooKassa) is resolved by integrations.payments;
|
|
124
142
|
// its checkout-<provider> feature registered it in the registry via lib/payments.ts.
|
|
125
143
|
return payments.resolve(this.siteConfig).createCheckout({ cart, baseUrl: this.baseUrl });
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Pure ProductQuery helpers — filter/sort in memory after the adapter loads products.
|
|
2
|
+
// Price lives on variants, so Payload cannot ORDER BY price in SQL without a denormalized
|
|
3
|
+
// field; this keeps the contract honest for catalog-sized datasets.
|
|
4
|
+
import type { Product, ProductQuery, ProductSort } from '@vitrine-kit/contracts';
|
|
5
|
+
|
|
6
|
+
export function parseProductSort(value: string | undefined | null): ProductSort | undefined {
|
|
7
|
+
if (value === 'newest' || value === 'price-asc' || value === 'price-desc' || value === 'relevance') {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Parse `?size=S&size=L&color=Black` style params into ProductQuery.filters. */
|
|
14
|
+
export function parseProductFilters(
|
|
15
|
+
params: Record<string, string | string[] | undefined>,
|
|
16
|
+
facetKeys: string[] = ['size', 'color'],
|
|
17
|
+
): Record<string, string[]> | undefined {
|
|
18
|
+
const filters: Record<string, string[]> = {};
|
|
19
|
+
for (const key of facetKeys) {
|
|
20
|
+
const raw = params[key];
|
|
21
|
+
if (raw == null) continue;
|
|
22
|
+
const values = (Array.isArray(raw) ? raw : [raw])
|
|
23
|
+
.flatMap((v) => String(v).split(','))
|
|
24
|
+
.map((v) => v.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
if (values.length) filters[key] = [...new Set(values)];
|
|
27
|
+
}
|
|
28
|
+
return Object.keys(filters).length ? filters : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parsePriceBound(
|
|
32
|
+
value: string | string[] | undefined | null,
|
|
33
|
+
): number | undefined {
|
|
34
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
35
|
+
if (raw == null || raw === '') return undefined;
|
|
36
|
+
const n = Number(raw);
|
|
37
|
+
if (!Number.isFinite(n) || n < 0) return undefined;
|
|
38
|
+
// URL values are major currency units ($); ProductQuery uses minor units.
|
|
39
|
+
return Math.round(n * 100);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Aggregate option values present on the given products (for toolbar facets). */
|
|
43
|
+
export function collectOptionFacets(products: Product[]): Record<string, string[]> {
|
|
44
|
+
const buckets = new Map<string, Set<string>>();
|
|
45
|
+
for (const p of products) {
|
|
46
|
+
for (const v of p.variants) {
|
|
47
|
+
if (!v.options) continue;
|
|
48
|
+
for (const [key, val] of Object.entries(v.options)) {
|
|
49
|
+
if (!val) continue;
|
|
50
|
+
let set = buckets.get(key);
|
|
51
|
+
if (!set) {
|
|
52
|
+
set = new Set();
|
|
53
|
+
buckets.set(key, set);
|
|
54
|
+
}
|
|
55
|
+
set.add(val);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const out: Record<string, string[]> = {};
|
|
60
|
+
for (const [key, set] of [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
61
|
+
out[key] = [...set].sort((a, b) => a.localeCompare(b));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function matchesFilters(product: Product, filters: Record<string, string[]>): boolean {
|
|
67
|
+
for (const [key, wanted] of Object.entries(filters)) {
|
|
68
|
+
if (!wanted.length) continue;
|
|
69
|
+
const has = product.variants.some((v) => {
|
|
70
|
+
const actual = v.options?.[key];
|
|
71
|
+
return actual != null && wanted.includes(actual);
|
|
72
|
+
});
|
|
73
|
+
if (!has) return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function matchesSearch(product: Product, term: string): boolean {
|
|
79
|
+
const q = term.trim().toLowerCase();
|
|
80
|
+
if (!q) return true;
|
|
81
|
+
const hay = `${product.title} ${product.description ?? ''} ${product.slug}`.toLowerCase();
|
|
82
|
+
return hay.includes(q);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function priceOf(product: Product): number {
|
|
86
|
+
return product.priceRange?.min ?? product.variants[0]?.price ?? Number.POSITIVE_INFINITY;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function matchesPrice(product: Product, priceMin?: number, priceMax?: number): boolean {
|
|
90
|
+
if (priceMin == null && priceMax == null) return true;
|
|
91
|
+
// A product matches if any variant price falls in range (merchandising expectation).
|
|
92
|
+
return product.variants.some((v) => {
|
|
93
|
+
if (priceMin != null && v.price < priceMin) return false;
|
|
94
|
+
if (priceMax != null && v.price > priceMax) return false;
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Apply search / filters / sort that the DB layer cannot express cheaply. */
|
|
100
|
+
export function applyProductQuery(products: Product[], query: ProductQuery = {}): Product[] {
|
|
101
|
+
let out = products;
|
|
102
|
+
if (query.search?.trim()) {
|
|
103
|
+
out = out.filter((p) => matchesSearch(p, query.search!));
|
|
104
|
+
}
|
|
105
|
+
if (query.filters && Object.keys(query.filters).length > 0) {
|
|
106
|
+
out = out.filter((p) => matchesFilters(p, query.filters!));
|
|
107
|
+
}
|
|
108
|
+
if (query.priceMin != null || query.priceMax != null) {
|
|
109
|
+
out = out.filter((p) => matchesPrice(p, query.priceMin, query.priceMax));
|
|
110
|
+
}
|
|
111
|
+
const sort = query.sort ?? 'newest';
|
|
112
|
+
if (sort === 'price-asc') {
|
|
113
|
+
out = [...out].sort((a, b) => priceOf(a) - priceOf(b));
|
|
114
|
+
} else if (sort === 'price-desc') {
|
|
115
|
+
out = [...out].sort((a, b) => priceOf(b) - priceOf(a));
|
|
116
|
+
} else if (sort === 'relevance' && query.search?.trim()) {
|
|
117
|
+
const q = query.search.trim().toLowerCase();
|
|
118
|
+
out = [...out].sort((a, b) => {
|
|
119
|
+
const aTitle = a.title.toLowerCase().startsWith(q) ? 0 : a.title.toLowerCase().includes(q) ? 1 : 2;
|
|
120
|
+
const bTitle = b.title.toLowerCase().startsWith(q) ? 0 : b.title.toLowerCase().includes(q) ? 1 : 2;
|
|
121
|
+
return aTitle - bTitle || a.title.localeCompare(b.title);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// newest: keep adapter order (typically -createdAt)
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// to the console once.
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
5
|
import type { Payload } from 'payload';
|
|
6
|
-
import { shouldRunDevTask } from './guards.js';
|
|
6
|
+
import { seedOnBootEnabled, shouldRunDevTask } from './guards.js';
|
|
7
7
|
|
|
8
8
|
function randomPassword(length = 16): string {
|
|
9
9
|
return randomBytes(length).toString('base64url').slice(0, length);
|
|
@@ -12,12 +12,22 @@ function randomPassword(length = 16): string {
|
|
|
12
12
|
export async function ensureDevAdmin(payload: Payload): Promise<void> {
|
|
13
13
|
const isProd = process.env.NODE_ENV === 'production';
|
|
14
14
|
const { totalDocs } = await payload.count({ collection: 'users' });
|
|
15
|
-
if (!shouldRunDevTask({ isProd, existingCount: totalDocs }))
|
|
15
|
+
if (!shouldRunDevTask({ isProd, existingCount: totalDocs, seedOnBoot: seedOnBootEnabled() })) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// `.env.example` ships `DEV_ADMIN_EMAIL=` / `DEV_ADMIN_PASSWORD=` — empty strings must
|
|
20
|
+
// not win over defaults (`??` only treats null/undefined).
|
|
21
|
+
const email = process.env.DEV_ADMIN_EMAIL?.trim() || 'admin@example.com';
|
|
22
|
+
const password = process.env.DEV_ADMIN_PASSWORD?.trim() || randomPassword();
|
|
23
|
+
await payload.create({
|
|
24
|
+
collection: 'users',
|
|
25
|
+
data: { email, password },
|
|
26
|
+
overrideAccess: true,
|
|
27
|
+
});
|
|
20
28
|
payload.logger.warn(
|
|
21
|
-
|
|
29
|
+
isProd
|
|
30
|
+
? `[vitrine] BOOTSTRAP ADMIN ${email} / ${password} — change immediately (SEED_ON_BOOT)`
|
|
31
|
+
: `[vitrine] DEV ADMIN ${email} / ${password} — development only, change before deploying`,
|
|
22
32
|
);
|
|
23
33
|
}
|