@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.
- package/README.md +1 -1
- package/dist/index.js +114 -45
- 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 +46 -8
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +38 -3
- 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 +9 -7
- package/kit/registry/catalog/feature.json +1 -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 +14 -3
- 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-stripe/feature.json +1 -1
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- 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 +3 -3
- 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 +7 -3
- 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 +57 -6
- package/kit/templates/backend-payload/files/next.config.mjs +8 -2
- package/kit/templates/backend-payload/files/payload.config.ts +27 -3
- 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/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +51 -4
- 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 +49 -4
- 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 +8 -2
- package/package.json +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { getCustomer } from '@/lib/accounts/session';
|
|
3
|
+
import { LogoutButton } from '@/components/accounts/LogoutButton';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Account',
|
|
7
|
+
robots: { index: false, follow: false },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default async function AccountPage() {
|
|
11
|
+
const customer = await getCustomer();
|
|
12
|
+
|
|
13
|
+
if (customer) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex max-w-md flex-col gap-section">
|
|
16
|
+
<header className="flex flex-col gap-unit">
|
|
17
|
+
<h1 className="font-heading text-fg">Your account</h1>
|
|
18
|
+
<p className="text-muted-fg">
|
|
19
|
+
Signed in as <span className="text-fg">{customer.email}</span>
|
|
20
|
+
{customer.name ? ` (${customer.name})` : ''}
|
|
21
|
+
</p>
|
|
22
|
+
</header>
|
|
23
|
+
<div className="flex flex-col gap-gutter">
|
|
24
|
+
<a
|
|
25
|
+
href="/account/orders"
|
|
26
|
+
className="w-fit rounded-md bg-primary px-gutter py-unit text-primary-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
27
|
+
>
|
|
28
|
+
View orders
|
|
29
|
+
</a>
|
|
30
|
+
<LogoutButton />
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex max-w-lg flex-col gap-section">
|
|
38
|
+
<header className="flex flex-col gap-unit">
|
|
39
|
+
<h1 className="font-heading text-fg">Account</h1>
|
|
40
|
+
<p className="text-muted-fg">
|
|
41
|
+
Sign in to see your orders, or look them up with the email used at checkout.
|
|
42
|
+
</p>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<div className="flex flex-wrap gap-gutter">
|
|
46
|
+
<a
|
|
47
|
+
href="/account/login"
|
|
48
|
+
className="rounded-md bg-primary px-gutter py-unit text-primary-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
49
|
+
>
|
|
50
|
+
Sign in
|
|
51
|
+
</a>
|
|
52
|
+
<a
|
|
53
|
+
href="/account/register"
|
|
54
|
+
className="rounded-md border border-input px-gutter py-unit text-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
55
|
+
>
|
|
56
|
+
Create account
|
|
57
|
+
</a>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<section className="flex flex-col gap-gutter border-t border-border pt-section">
|
|
61
|
+
<h2 className="font-heading text-fg">Guest order lookup</h2>
|
|
62
|
+
<p className="text-muted-fg text-sm">
|
|
63
|
+
Enter the email used at checkout. We show matching paid orders from this store.
|
|
64
|
+
</p>
|
|
65
|
+
<form action="/account/orders" method="get" className="flex flex-col gap-gutter">
|
|
66
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
67
|
+
<span className="text-fg">Email</span>
|
|
68
|
+
<input
|
|
69
|
+
type="email"
|
|
70
|
+
name="email"
|
|
71
|
+
required
|
|
72
|
+
autoComplete="email"
|
|
73
|
+
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"
|
|
74
|
+
/>
|
|
75
|
+
</label>
|
|
76
|
+
<button
|
|
77
|
+
type="submit"
|
|
78
|
+
className="w-fit rounded-md border border-input px-gutter py-unit text-fg focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
79
|
+
>
|
|
80
|
+
Look up orders
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
</section>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { RegisterForm } from '@/components/accounts/RegisterForm';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Create account',
|
|
6
|
+
robots: { index: false, follow: false },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function AccountRegisterPage() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex max-w-md flex-col gap-section">
|
|
12
|
+
<header className="flex flex-col gap-unit">
|
|
13
|
+
<h1 className="font-heading text-fg">Create account</h1>
|
|
14
|
+
<p className="text-muted-fg">
|
|
15
|
+
Already have an account?{' '}
|
|
16
|
+
<a href="/account/login" className="underline underline-offset-2">
|
|
17
|
+
Sign in
|
|
18
|
+
</a>
|
|
19
|
+
</p>
|
|
20
|
+
</header>
|
|
21
|
+
<RegisterForm />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { ResetPasswordForm } from '@/components/accounts/ResetPasswordForm';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Reset password',
|
|
6
|
+
robots: { index: false, follow: false },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
interface PageProps {
|
|
10
|
+
searchParams: Promise<{ token?: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function ResetPasswordPage({ searchParams }: PageProps) {
|
|
14
|
+
const { token = '' } = await searchParams;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex max-w-md flex-col gap-section">
|
|
18
|
+
<header className="flex flex-col gap-unit">
|
|
19
|
+
<h1 className="font-heading text-fg">Choose a new password</h1>
|
|
20
|
+
<p className="text-muted-fg">Use at least 8 characters.</p>
|
|
21
|
+
</header>
|
|
22
|
+
<ResetPasswordForm token={token} />
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// GET /api/account/orders?email= — rate-limited guest order lookup.
|
|
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
|
+
|
|
7
|
+
const RATE_LIMIT = { limit: 10, windowMs: 60_000 };
|
|
8
|
+
|
|
9
|
+
export async function GET(req: Request) {
|
|
10
|
+
const { allowed, retryAfterMs } = checkRateLimit(`account:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
11
|
+
if (!allowed) {
|
|
12
|
+
return NextResponse.json(
|
|
13
|
+
{ error: 'too many requests' },
|
|
14
|
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 0) / 1000)) } },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const email = new URL(req.url).searchParams.get('email')?.trim().toLowerCase() ?? '';
|
|
18
|
+
if (!email?.includes('@')) {
|
|
19
|
+
return NextResponse.json({ error: 'email required' }, { status: 400 });
|
|
20
|
+
}
|
|
21
|
+
const payload = await getPayload({ config });
|
|
22
|
+
const res = await payload.find({
|
|
23
|
+
collection: 'orders',
|
|
24
|
+
where: { email: { equals: email } },
|
|
25
|
+
sort: '-createdAt',
|
|
26
|
+
limit: 50,
|
|
27
|
+
overrideAccess: true,
|
|
28
|
+
});
|
|
29
|
+
return NextResponse.json({
|
|
30
|
+
orders: res.docs.map((d) => ({
|
|
31
|
+
id: String(d.id),
|
|
32
|
+
status: (d as { status?: string }).status,
|
|
33
|
+
total: (d as { total?: number }).total,
|
|
34
|
+
currency: (d as { currency?: string }).currency,
|
|
35
|
+
createdAt: (d as { createdAt?: string }).createdAt,
|
|
36
|
+
paymentRef: (d as { paymentRef?: string }).paymentRef,
|
|
37
|
+
})),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
4
|
+
|
|
5
|
+
export function AccountLink() {
|
|
6
|
+
const label = useChromeLabel('account');
|
|
7
|
+
return (
|
|
8
|
+
<a
|
|
9
|
+
href="/account"
|
|
10
|
+
className="vt-account-link text-fg transition hover:text-primary focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
11
|
+
>
|
|
12
|
+
{label}
|
|
13
|
+
</a>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export function ForgotPasswordForm() {
|
|
6
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
7
|
+
const [error, setError] = useState<string | null>(null);
|
|
8
|
+
const [done, setDone] = useState(false);
|
|
9
|
+
const [pending, setPending] = useState(false);
|
|
10
|
+
|
|
11
|
+
async function submit(): Promise<void> {
|
|
12
|
+
const form = formRef.current;
|
|
13
|
+
if (!form) return;
|
|
14
|
+
setPending(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
const email = String(new FormData(form).get('email') ?? '');
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch('/api/customers/forgot-password', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ email }),
|
|
22
|
+
});
|
|
23
|
+
// Always show success copy — avoid email enumeration.
|
|
24
|
+
if (!res.ok && res.status !== 200) {
|
|
25
|
+
const data = (await res.json().catch(() => ({}))) as { errors?: Array<{ message?: string }> };
|
|
26
|
+
// Payload may still 200 for unknown emails; only surface hard failures.
|
|
27
|
+
if (res.status >= 500) {
|
|
28
|
+
setError(data.errors?.[0]?.message ?? 'Could not send reset email.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
setDone(true);
|
|
33
|
+
} catch {
|
|
34
|
+
setError('Could not send reset email.');
|
|
35
|
+
} finally {
|
|
36
|
+
setPending(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (done) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-col gap-unit text-muted-fg" role="status">
|
|
43
|
+
<p>
|
|
44
|
+
If an account exists for that email, a reset link is on its way. Check your inbox.
|
|
45
|
+
</p>
|
|
46
|
+
<p className="text-sm">
|
|
47
|
+
Without SMTP, the link is printed in the server console /{' '}
|
|
48
|
+
<code className="text-fg">docker compose logs</code> (set{' '}
|
|
49
|
+
<code className="text-fg">EMAIL_FROM</code>; add <code className="text-fg">SMTP_HOST</code>{' '}
|
|
50
|
+
for real delivery).
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<form
|
|
58
|
+
ref={formRef}
|
|
59
|
+
className="vt-forgot-password-form flex flex-col gap-gutter"
|
|
60
|
+
onSubmit={(e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
void submit();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
66
|
+
<span className="text-fg">Email</span>
|
|
67
|
+
<input
|
|
68
|
+
type="email"
|
|
69
|
+
name="email"
|
|
70
|
+
required
|
|
71
|
+
autoComplete="email"
|
|
72
|
+
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"
|
|
73
|
+
/>
|
|
74
|
+
</label>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => void submit()}
|
|
78
|
+
disabled={pending}
|
|
79
|
+
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"
|
|
80
|
+
>
|
|
81
|
+
{pending ? 'Sending…' : 'Send reset link'}
|
|
82
|
+
</button>
|
|
83
|
+
{error ? (
|
|
84
|
+
<p role="alert" className="text-danger">
|
|
85
|
+
{error}
|
|
86
|
+
</p>
|
|
87
|
+
) : null}
|
|
88
|
+
</form>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
export function LoginForm() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [pending, setPending] = useState(false);
|
|
11
|
+
|
|
12
|
+
async function signIn(): Promise<void> {
|
|
13
|
+
const form = formRef.current;
|
|
14
|
+
if (!form) return;
|
|
15
|
+
setPending(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
const fd = new FormData(form);
|
|
18
|
+
const email = String(fd.get('email') ?? '');
|
|
19
|
+
const password = String(fd.get('password') ?? '');
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch('/api/customers/login', {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ email, password }),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const data = (await res.json().catch(() => ({}))) as { errors?: Array<{ message?: string }> };
|
|
28
|
+
setError(data.errors?.[0]?.message ?? 'Could not sign in.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
router.push('/account/orders');
|
|
32
|
+
router.refresh();
|
|
33
|
+
} catch {
|
|
34
|
+
setError('Could not sign in.');
|
|
35
|
+
} finally {
|
|
36
|
+
setPending(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form
|
|
42
|
+
ref={formRef}
|
|
43
|
+
className="vt-login-form flex flex-col gap-gutter"
|
|
44
|
+
onSubmit={(e) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
void signIn();
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
50
|
+
<span className="text-fg">Email</span>
|
|
51
|
+
<input
|
|
52
|
+
type="email"
|
|
53
|
+
name="email"
|
|
54
|
+
required
|
|
55
|
+
autoComplete="email"
|
|
56
|
+
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"
|
|
57
|
+
/>
|
|
58
|
+
</label>
|
|
59
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
60
|
+
<span className="text-fg">Password</span>
|
|
61
|
+
<input
|
|
62
|
+
type="password"
|
|
63
|
+
name="password"
|
|
64
|
+
required
|
|
65
|
+
autoComplete="current-password"
|
|
66
|
+
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"
|
|
67
|
+
/>
|
|
68
|
+
</label>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => void signIn()}
|
|
72
|
+
disabled={pending}
|
|
73
|
+
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"
|
|
74
|
+
>
|
|
75
|
+
{pending ? 'Signing in…' : 'Sign in'}
|
|
76
|
+
</button>
|
|
77
|
+
{error ? (
|
|
78
|
+
<p role="alert" className="text-danger">
|
|
79
|
+
{error}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
</form>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
export function LogoutButton() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
const [pending, setPending] = useState(false);
|
|
9
|
+
|
|
10
|
+
async function logout(): Promise<void> {
|
|
11
|
+
setPending(true);
|
|
12
|
+
try {
|
|
13
|
+
await fetch('/api/customers/logout', { method: 'POST' });
|
|
14
|
+
router.push('/account');
|
|
15
|
+
router.refresh();
|
|
16
|
+
} finally {
|
|
17
|
+
setPending(false);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={logout}
|
|
25
|
+
disabled={pending}
|
|
26
|
+
className="w-fit text-fg underline underline-offset-2 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
27
|
+
>
|
|
28
|
+
{pending ? 'Signing out…' : 'Sign out'}
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
export function RegisterForm() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const [pending, setPending] = useState(false);
|
|
11
|
+
|
|
12
|
+
async function createAccount(): Promise<void> {
|
|
13
|
+
const form = formRef.current;
|
|
14
|
+
if (!form) return;
|
|
15
|
+
setPending(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
const fd = new FormData(form);
|
|
18
|
+
const name = String(fd.get('name') ?? '').trim();
|
|
19
|
+
const email = String(fd.get('email') ?? '');
|
|
20
|
+
const password = String(fd.get('password') ?? '');
|
|
21
|
+
try {
|
|
22
|
+
const create = await fetch('/api/customers', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ email, password, name: name || undefined }),
|
|
26
|
+
});
|
|
27
|
+
if (!create.ok) {
|
|
28
|
+
const data = (await create.json().catch(() => ({}))) as {
|
|
29
|
+
errors?: Array<{ message?: string }>;
|
|
30
|
+
message?: string;
|
|
31
|
+
};
|
|
32
|
+
setError(data.errors?.[0]?.message ?? data.message ?? 'Could not create account.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const login = await fetch('/api/customers/login', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ email, password }),
|
|
39
|
+
});
|
|
40
|
+
if (!login.ok) {
|
|
41
|
+
setError('Account created — please sign in.');
|
|
42
|
+
router.push('/account/login');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
router.push('/account/orders');
|
|
46
|
+
router.refresh();
|
|
47
|
+
} catch {
|
|
48
|
+
setError('Could not create account.');
|
|
49
|
+
} finally {
|
|
50
|
+
setPending(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<form
|
|
56
|
+
ref={formRef}
|
|
57
|
+
className="vt-register-form flex flex-col gap-gutter"
|
|
58
|
+
onSubmit={(e) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
void createAccount();
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
64
|
+
<span className="text-fg">Name</span>
|
|
65
|
+
<input
|
|
66
|
+
type="text"
|
|
67
|
+
name="name"
|
|
68
|
+
autoComplete="name"
|
|
69
|
+
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"
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
73
|
+
<span className="text-fg">Email</span>
|
|
74
|
+
<input
|
|
75
|
+
type="email"
|
|
76
|
+
name="email"
|
|
77
|
+
required
|
|
78
|
+
autoComplete="email"
|
|
79
|
+
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"
|
|
80
|
+
/>
|
|
81
|
+
</label>
|
|
82
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
83
|
+
<span className="text-fg">Password</span>
|
|
84
|
+
<input
|
|
85
|
+
type="password"
|
|
86
|
+
name="password"
|
|
87
|
+
required
|
|
88
|
+
minLength={8}
|
|
89
|
+
autoComplete="new-password"
|
|
90
|
+
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"
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => void createAccount()}
|
|
96
|
+
disabled={pending}
|
|
97
|
+
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"
|
|
98
|
+
>
|
|
99
|
+
{pending ? 'Creating…' : 'Create account'}
|
|
100
|
+
</button>
|
|
101
|
+
{error ? (
|
|
102
|
+
<p role="alert" className="text-danger">
|
|
103
|
+
{error}
|
|
104
|
+
</p>
|
|
105
|
+
) : null}
|
|
106
|
+
</form>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
export interface ResetPasswordFormProps {
|
|
7
|
+
token: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [pending, setPending] = useState(false);
|
|
15
|
+
|
|
16
|
+
async function submit(): Promise<void> {
|
|
17
|
+
const form = formRef.current;
|
|
18
|
+
if (!form || !token) return;
|
|
19
|
+
setPending(true);
|
|
20
|
+
setError(null);
|
|
21
|
+
const password = String(new FormData(form).get('password') ?? '');
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch('/api/customers/reset-password', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({ token, password }),
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
30
|
+
errors?: Array<{ message?: string }>;
|
|
31
|
+
message?: string;
|
|
32
|
+
};
|
|
33
|
+
setError(data.errors?.[0]?.message ?? data.message ?? 'Could not reset password.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
router.push('/account/login');
|
|
37
|
+
router.refresh();
|
|
38
|
+
} catch {
|
|
39
|
+
setError('Could not reset password.');
|
|
40
|
+
} finally {
|
|
41
|
+
setPending(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!token) {
|
|
46
|
+
return <p className="text-muted-fg">Missing reset token. Request a new link from the login page.</p>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<form
|
|
51
|
+
ref={formRef}
|
|
52
|
+
className="vt-reset-password-form flex flex-col gap-gutter"
|
|
53
|
+
onSubmit={(e) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
void submit();
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<label className="flex flex-col gap-unit text-sm">
|
|
59
|
+
<span className="text-fg">New password</span>
|
|
60
|
+
<input
|
|
61
|
+
type="password"
|
|
62
|
+
name="password"
|
|
63
|
+
required
|
|
64
|
+
minLength={8}
|
|
65
|
+
autoComplete="new-password"
|
|
66
|
+
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"
|
|
67
|
+
/>
|
|
68
|
+
</label>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => void submit()}
|
|
72
|
+
disabled={pending}
|
|
73
|
+
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"
|
|
74
|
+
>
|
|
75
|
+
{pending ? 'Saving…' : 'Update password'}
|
|
76
|
+
</button>
|
|
77
|
+
{error ? (
|
|
78
|
+
<p role="alert" className="text-danger">
|
|
79
|
+
{error}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
</form>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BlueprintFieldDef, Extend } from '@vitrine-kit/contracts';
|
|
2
|
+
|
|
3
|
+
type BlueprintLike = {
|
|
4
|
+
extend: Extend;
|
|
5
|
+
addCollection: (config: {
|
|
6
|
+
slug: string;
|
|
7
|
+
fields: BlueprintFieldDef[];
|
|
8
|
+
[option: string]: unknown;
|
|
9
|
+
}) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const isAdmin = ({ req }: { req?: { user?: { collection?: string } } }): boolean =>
|
|
13
|
+
Boolean(req?.user && req.user.collection === 'users');
|
|
14
|
+
|
|
15
|
+
/** Customers can read/update only themselves; admins can manage all. */
|
|
16
|
+
const selfOrAdmin = ({ req }: { req?: { user?: { id?: string | number; collection?: string } } }) => {
|
|
17
|
+
if (!req?.user) return false;
|
|
18
|
+
if (req.user.collection === 'users') return true;
|
|
19
|
+
return { id: { equals: req.user.id } };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function extendAccountsBlueprint(blueprint: BlueprintLike): void {
|
|
23
|
+
blueprint.addCollection({
|
|
24
|
+
slug: 'customers',
|
|
25
|
+
auth: { maxLoginAttempts: 5, lockTime: 10 * 60 * 1000 },
|
|
26
|
+
admin: { useAsTitle: 'email', defaultColumns: ['email', 'name'] },
|
|
27
|
+
access: {
|
|
28
|
+
create: () => true,
|
|
29
|
+
read: selfOrAdmin,
|
|
30
|
+
update: selfOrAdmin,
|
|
31
|
+
delete: isAdmin,
|
|
32
|
+
admin: isAdmin,
|
|
33
|
+
},
|
|
34
|
+
fields: [{ name: 'name', type: 'text', label: 'Name' }],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
blueprint.extend('order', {
|
|
38
|
+
addFields: [
|
|
39
|
+
{
|
|
40
|
+
name: 'customer',
|
|
41
|
+
type: 'relationship',
|
|
42
|
+
relationTo: 'customers',
|
|
43
|
+
label: 'Customer',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Server helpers for the storefront customer session (Payload auth collection `customers`).
|
|
2
|
+
import { headers } from 'next/headers';
|
|
3
|
+
import { getPayload } from 'payload';
|
|
4
|
+
import config from '@payload-config';
|
|
5
|
+
|
|
6
|
+
export type CustomerUser = {
|
|
7
|
+
id: string | number;
|
|
8
|
+
email?: string | null;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
collection?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function getCustomer(): Promise<CustomerUser | null> {
|
|
14
|
+
const payload = await getPayload({ config });
|
|
15
|
+
const { user } = await payload.auth({ headers: await headers() });
|
|
16
|
+
if (!user || (user as CustomerUser).collection !== 'customers') return null;
|
|
17
|
+
return user as CustomerUser;
|
|
18
|
+
}
|