@jarwizz/create-jarshop 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +8 -0
  2. package/dist/generator.js +6 -3
  3. package/dist/generator.js.map +1 -1
  4. package/dist/template/CONTEXT.md +10 -0
  5. package/dist/template/README.md +20 -3
  6. package/dist/template/docker-compose.yml +5 -0
  7. package/dist/template/docs/smtp-smoke.md +37 -0
  8. package/dist/template/env.example.template +18 -0
  9. package/dist/template/gitignore.template +2 -0
  10. package/dist/template/package.json +13 -5
  11. package/dist/template/pnpm-lock.yaml +38 -7
  12. package/dist/template/src/dev-storefront.ts +58 -0
  13. package/dist/template/src/dev.ts +67 -4
  14. package/dist/template/src/email-smoke.ts +27 -0
  15. package/dist/template/src/environment.d.ts +6 -0
  16. package/dist/template/src/migrate.ts +16 -0
  17. package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
  18. package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
  19. package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
  20. package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
  21. package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
  22. package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
  23. package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +26 -0
  24. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
  25. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
  26. package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
  27. package/dist/template/src/plugins/jarshop-checkout/index.ts +47 -0
  28. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +813 -0
  29. package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
  30. package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
  31. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
  32. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
  33. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
  34. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
  35. package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
  36. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
  37. package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
  38. package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
  39. package/dist/template/src/seed.ts +149 -0
  40. package/dist/template/src/vendure-config.ts +68 -5
  41. package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
  42. package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
  43. package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
  44. package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
  45. package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
  46. package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
  47. package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
  48. package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
  49. package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
  50. package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
  51. package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
  52. package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
  53. package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
  54. package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
  55. package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
  56. package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
  57. package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
  58. package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
  59. package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
  60. package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
  61. package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +23 -0
  62. package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
  63. package/dist/template/storefront/app/[locale]/page.tsx +10 -11
  64. package/dist/template/storefront/app/globals.css +372 -4
  65. package/dist/template/storefront/app/layout.tsx +13 -0
  66. package/dist/template/storefront/eslint.config.mjs +1 -1
  67. package/dist/template/storefront/gitignore.template +1 -0
  68. package/dist/template/storefront/lib/account-security.test.ts +74 -0
  69. package/dist/template/storefront/lib/account-security.ts +81 -0
  70. package/dist/template/storefront/lib/account.ts +366 -0
  71. package/dist/template/storefront/lib/cart-contract.ts +52 -0
  72. package/dist/template/storefront/lib/cart.test.ts +30 -0
  73. package/dist/template/storefront/lib/cart.ts +130 -0
  74. package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
  75. package/dist/template/storefront/lib/checkout-contract.ts +16 -0
  76. package/dist/template/storefront/lib/checkout.ts +143 -0
  77. package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
  78. package/dist/template/storefront/lib/commerce-session.ts +85 -0
  79. package/dist/template/storefront/lib/commerce.ts +122 -1
  80. package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
  81. package/dist/template/storefront/lib/confirmation.ts +47 -0
  82. package/dist/template/storefront/lib/form-values.test.ts +14 -0
  83. package/dist/template/storefront/lib/form-values.ts +7 -0
  84. package/dist/template/storefront/lib/i18n.test.ts +50 -0
  85. package/dist/template/storefront/lib/i18n.ts +44 -0
  86. package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
  87. package/dist/template/storefront/lib/password-reset-token.ts +17 -0
  88. package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
  89. package/dist/template/storefront/messages/en.json +89 -0
  90. package/dist/template/storefront/messages/sk.json +89 -0
  91. package/dist/template/storefront/next.config.ts +3 -1
  92. package/dist/template/storefront/package.json +4 -1
  93. package/dist/template/storefront/pnpm-lock.yaml +756 -2
  94. package/dist/template/tsconfig.json +2 -1
  95. package/dist/template/turbo.json +1 -0
  96. package/dist/template/vite.config.mts +4 -1
  97. package/dist/template-manifest.json +1 -1
  98. package/dist/template.js +3 -0
  99. package/dist/template.js.map +1 -1
  100. package/package.json +1 -1
@@ -0,0 +1,33 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import {
4
+ PASSWORD_RESET_TOKEN_COOKIE,
5
+ passwordResetTokenCookieOptions,
6
+ } from "../../../../../lib/password-reset-token";
7
+ import { parseLocale } from "../../../../../lib/i18n";
8
+
9
+ interface PasswordResetCallbackContext {
10
+ params: Promise<{ locale: string }>;
11
+ }
12
+
13
+ export async function GET(
14
+ request: NextRequest,
15
+ context: PasswordResetCallbackContext,
16
+ ): Promise<NextResponse> {
17
+ const locale = parseLocale((await context.params).locale);
18
+ const resetUrl = new URL(
19
+ `/${locale}/account/password-reset/complete`,
20
+ request.url,
21
+ );
22
+ const requestUrl = new URL(`/${locale}/account/password-reset`, request.url);
23
+ const token = request.nextUrl.searchParams.get("token")?.trim();
24
+ if (!token) return NextResponse.redirect(requestUrl);
25
+
26
+ const response = NextResponse.redirect(resetUrl);
27
+ response.cookies.set(
28
+ PASSWORD_RESET_TOKEN_COOKIE,
29
+ token,
30
+ passwordResetTokenCookieOptions,
31
+ );
32
+ return response;
33
+ }
@@ -0,0 +1,27 @@
1
+ import { resetPassword } from "../../../../../lib/account";
2
+ import { createTranslator, parseLocale } from "../../../../../lib/i18n";
3
+ import { ResetPasswordForm } from "../password-reset-forms";
4
+
5
+ interface CompletePasswordResetPageProps {
6
+ params: Promise<{ locale: string }>;
7
+ }
8
+
9
+ export default async function CompletePasswordResetPage({
10
+ params,
11
+ }: CompletePasswordResetPageProps) {
12
+ const language = parseLocale((await params).locale);
13
+ const t = createTranslator(language);
14
+
15
+ return (
16
+ <main className="page" lang={language}>
17
+ <div className="page-heading">
18
+ <p className="eyebrow">JarShop starter</p>
19
+ <h1>{t("account.resetPassword")}</h1>
20
+ </div>
21
+ <ResetPasswordForm
22
+ locale={language}
23
+ resetAction={resetPassword.bind(null, language)}
24
+ />
25
+ </main>
26
+ );
27
+ }
@@ -0,0 +1,37 @@
1
+ import { requestPasswordReset } from "../../../../lib/account";
2
+ import { createTranslator, parseLocale } from "../../../../lib/i18n";
3
+ import { PasswordResetRequestForm } from "./password-reset-forms";
4
+ import { redirect } from "next/navigation";
5
+
6
+ interface PasswordResetPageProps {
7
+ params: Promise<{ locale: string }>;
8
+ searchParams: Promise<{ token?: string | string[] }>;
9
+ }
10
+
11
+ export default async function PasswordResetPage({
12
+ params,
13
+ searchParams,
14
+ }: PasswordResetPageProps) {
15
+ const { locale } = await params;
16
+ const { token } = await searchParams;
17
+ const language = parseLocale(locale);
18
+ if (typeof token === "string" && token.trim()) {
19
+ redirect(
20
+ `/${language}/account/password-reset/callback?token=${encodeURIComponent(token)}`,
21
+ );
22
+ }
23
+ const t = createTranslator(language);
24
+
25
+ return (
26
+ <main className="page" lang={language}>
27
+ <div className="page-heading">
28
+ <p className="eyebrow">JarShop starter</p>
29
+ <h1>{t("account.resetPassword")}</h1>
30
+ </div>
31
+ <PasswordResetRequestForm
32
+ locale={language}
33
+ requestAction={requestPasswordReset.bind(null, language)}
34
+ />
35
+ </main>
36
+ );
37
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+
5
+ import type { AccountActionState } from "../../../../lib/account";
6
+ import type { CommerceLocale } from "../../../../lib/commerce";
7
+ import { createTranslator } from "../../../../lib/i18n";
8
+
9
+ interface PasswordResetRequestFormProps {
10
+ locale: CommerceLocale;
11
+ requestAction: (
12
+ previousState: AccountActionState,
13
+ formData: FormData,
14
+ ) => Promise<AccountActionState>;
15
+ }
16
+
17
+ interface ResetPasswordFormProps {
18
+ locale: CommerceLocale;
19
+ resetAction: (
20
+ previousState: AccountActionState,
21
+ formData: FormData,
22
+ ) => Promise<AccountActionState>;
23
+ }
24
+
25
+ export function PasswordResetRequestForm({
26
+ locale,
27
+ requestAction,
28
+ }: PasswordResetRequestFormProps) {
29
+ const [requestState, requestFormAction, requestPending] = useActionState(
30
+ requestAction,
31
+ { status: "idle" },
32
+ );
33
+ const t = createTranslator(locale);
34
+
35
+ return (
36
+ <form action={requestFormAction} className="checkout-form">
37
+ <label>
38
+ {t("account.email")}
39
+ <input autoComplete="email" name="emailAddress" required type="email" />
40
+ </label>
41
+ {requestState.status === "success" ? (
42
+ <p role="status">{t("account.resetSent")}</p>
43
+ ) : null}
44
+ {requestState.status === "error" ? (
45
+ <p role="alert">{t("account.signInFailed")}</p>
46
+ ) : null}
47
+ <button disabled={requestPending} type="submit">
48
+ {t("account.requestPasswordReset")}
49
+ </button>
50
+ </form>
51
+ );
52
+ }
53
+
54
+ export function ResetPasswordForm({
55
+ locale,
56
+ resetAction,
57
+ }: ResetPasswordFormProps) {
58
+ const [state, formAction, pending] = useActionState(resetAction, {
59
+ status: "idle",
60
+ });
61
+ const t = createTranslator(locale);
62
+
63
+ return (
64
+ <form action={formAction} className="checkout-form">
65
+ <label>
66
+ {t("account.newPassword")}
67
+ <input
68
+ autoComplete="new-password"
69
+ name="password"
70
+ required
71
+ type="password"
72
+ />
73
+ </label>
74
+ <label>
75
+ {t("account.confirmNewPassword")}
76
+ <input
77
+ autoComplete="new-password"
78
+ name="confirmPassword"
79
+ required
80
+ type="password"
81
+ />
82
+ </label>
83
+ {state.status === "error" ? (
84
+ <p role="alert">
85
+ {t(
86
+ state.reason === "passwordMismatch"
87
+ ? "account.passwordMismatch"
88
+ : state.reason === "passwordInvalid"
89
+ ? "account.passwordInvalid"
90
+ : "account.invalidToken",
91
+ )}
92
+ </p>
93
+ ) : null}
94
+ <button disabled={pending} type="submit">
95
+ {t("account.resetPassword")}
96
+ </button>
97
+ </form>
98
+ );
99
+ }
@@ -0,0 +1,23 @@
1
+ import { register } from "../../../../lib/account";
2
+ import { createTranslator, parseLocale } from "../../../../lib/i18n";
3
+ import { RegisterForm } from "./register-form";
4
+
5
+ interface RegisterPageProps {
6
+ params: Promise<{ locale: string }>;
7
+ }
8
+
9
+ export default async function RegisterPage({ params }: RegisterPageProps) {
10
+ const { locale } = await params;
11
+ const language = parseLocale(locale);
12
+ const t = createTranslator(language);
13
+
14
+ return (
15
+ <main className="page" lang={language}>
16
+ <div className="page-heading">
17
+ <p className="eyebrow">JarShop starter</p>
18
+ <h1>{t("account.register")}</h1>
19
+ </div>
20
+ <RegisterForm locale={language} action={register.bind(null, language)} />
21
+ </main>
22
+ );
23
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+
5
+ import type { AccountActionState } from "../../../../lib/account";
6
+ import type { CommerceLocale } from "../../../../lib/commerce";
7
+ import { createTranslator } from "../../../../lib/i18n";
8
+
9
+ interface RegisterFormProps {
10
+ locale: CommerceLocale;
11
+ action: (
12
+ previousState: AccountActionState,
13
+ formData: FormData,
14
+ ) => Promise<AccountActionState>;
15
+ }
16
+
17
+ export function RegisterForm({ locale, action }: RegisterFormProps) {
18
+ const [state, formAction, pending] = useActionState(action, {
19
+ status: "idle",
20
+ });
21
+ const t = createTranslator(locale);
22
+
23
+ return (
24
+ <form action={formAction} className="checkout-form">
25
+ <label>
26
+ {t("account.email")}
27
+ <input autoComplete="email" name="emailAddress" required type="email" />
28
+ </label>
29
+ <label>
30
+ {t("checkout.firstName")}
31
+ <input autoComplete="given-name" name="firstName" required />
32
+ </label>
33
+ <label>
34
+ {t("checkout.lastName")}
35
+ <input autoComplete="family-name" name="lastName" required />
36
+ </label>
37
+ <label>
38
+ {t("account.phone")}
39
+ <input autoComplete="tel" name="phoneNumber" type="tel" />
40
+ </label>
41
+ <label>
42
+ {t("account.password")}
43
+ <input
44
+ autoComplete="new-password"
45
+ name="password"
46
+ required
47
+ type="password"
48
+ />
49
+ </label>
50
+ {state.status === "success" ? (
51
+ <p role="status">{t("account.registrationSent")}</p>
52
+ ) : null}
53
+ {state.status === "error" ? (
54
+ <p role="alert">
55
+ {t(
56
+ state.reason === "passwordInvalid"
57
+ ? "account.passwordInvalid"
58
+ : "account.signInFailed",
59
+ )}
60
+ </p>
61
+ ) : null}
62
+ <button disabled={pending} type="submit">
63
+ {t("account.register")}
64
+ </button>
65
+ </form>
66
+ );
67
+ }
@@ -0,0 +1,33 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ import { completeAccountVerification } from "../../../../../lib/account";
4
+ import { createCommerceSessionStore } from "../../../../../lib/commerce-session";
5
+ import { parseLocale } from "../../../../../lib/i18n";
6
+
7
+ interface VerifyAccountCallbackContext {
8
+ params: Promise<{ locale: string }>;
9
+ }
10
+
11
+ export async function GET(
12
+ request: NextRequest,
13
+ context: VerifyAccountCallbackContext,
14
+ ): Promise<NextResponse> {
15
+ const locale = parseLocale((await context.params).locale);
16
+ const accountUrl = new URL(`/${locale}/account`, request.url);
17
+ const invalidUrl = new URL(`/${locale}/account/verify`, request.url);
18
+ const response = NextResponse.redirect(accountUrl);
19
+ const session = createCommerceSessionStore({
20
+ get: (name) => request.cookies.get(name),
21
+ set: (name, value, options) => response.cookies.set(name, value, options),
22
+ delete: (name) => response.cookies.delete(name),
23
+ });
24
+ const token = request.nextUrl.searchParams.get("token")?.trim() ?? "";
25
+ const verified = await completeAccountVerification(
26
+ locale,
27
+ session,
28
+ token,
29
+ request.headers,
30
+ );
31
+
32
+ return verified ? response : NextResponse.redirect(invalidUrl);
33
+ }
@@ -0,0 +1,41 @@
1
+ import { createTranslator, parseLocale } from "../../../../lib/i18n";
2
+ import { redirect } from "next/navigation";
3
+ import { refreshVerification } from "../../../../lib/account";
4
+ import { RefreshVerificationForm } from "./refresh-form";
5
+
6
+ interface VerifyPageProps {
7
+ params: Promise<{ locale: string }>;
8
+ searchParams: Promise<{ token?: string | string[] }>;
9
+ }
10
+
11
+ export default async function VerifyPage({
12
+ params,
13
+ searchParams,
14
+ }: VerifyPageProps) {
15
+ const { locale } = await params;
16
+ const { token } = await searchParams;
17
+ const language = parseLocale(locale);
18
+ const t = createTranslator(language);
19
+
20
+ if (typeof token === "string" && token) {
21
+ redirect(
22
+ `/${language}/account/verify/complete?token=${encodeURIComponent(token)}`,
23
+ );
24
+ }
25
+
26
+ return (
27
+ <main className="page" lang={language}>
28
+ <div className="page-heading">
29
+ <p className="eyebrow">JarShop starter</p>
30
+ <h1>{t("account.verify")}</h1>
31
+ </div>
32
+ <p role="alert">{t("account.invalidToken")}</p>
33
+ <RefreshVerificationForm
34
+ action={refreshVerification.bind(null, language)}
35
+ emailLabel={t("account.email")}
36
+ sentMessage={t("account.verificationSent")}
37
+ submitLabel={t("account.resendVerification")}
38
+ />
39
+ </main>
40
+ );
41
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+
5
+ import type { AccountActionState } from "../../../../lib/account";
6
+
7
+ interface RefreshVerificationFormProps {
8
+ action: (
9
+ previousState: AccountActionState,
10
+ formData: FormData,
11
+ ) => Promise<AccountActionState>;
12
+ emailLabel: string;
13
+ sentMessage: string;
14
+ submitLabel: string;
15
+ }
16
+
17
+ export function RefreshVerificationForm(props: RefreshVerificationFormProps) {
18
+ const [state, formAction, pending] = useActionState(props.action, {
19
+ status: "idle",
20
+ });
21
+ return (
22
+ <form action={formAction} className="checkout-form">
23
+ <label>
24
+ {props.emailLabel}
25
+ <input name="emailAddress" required type="email" />
26
+ </label>
27
+ {state.status === "success" ? (
28
+ <p role="status">{props.sentMessage}</p>
29
+ ) : null}
30
+ <button disabled={pending} type="submit">
31
+ {props.submitLabel}
32
+ </button>
33
+ </form>
34
+ );
35
+ }
@@ -0,0 +1,78 @@
1
+ import { getCart, removeCartLine, updateCartLine } from "../../../lib/cart";
2
+ import { createTranslator, parseLocale } from "../../../lib/i18n";
3
+
4
+ interface CartPageProps {
5
+ params: Promise<{ locale: string }>;
6
+ }
7
+
8
+ function formatMoney(amount: number, currencyCode: string): string {
9
+ return `${(amount / 100).toFixed(2)} ${currencyCode}`;
10
+ }
11
+
12
+ export default async function CartPage({ params }: CartPageProps) {
13
+ const { locale } = await params;
14
+ const language = parseLocale(locale);
15
+ const t = createTranslator(language);
16
+ const cart = await getCart(language);
17
+
18
+ return (
19
+ <main className="page" lang={language}>
20
+ <div className="page-heading">
21
+ <p className="eyebrow">JarShop starter</p>
22
+ <h1>{t("cart.title")}</h1>
23
+ </div>
24
+ {!cart || cart.lines.length === 0 ? (
25
+ <p>{t("cart.empty")}</p>
26
+ ) : (
27
+ <>
28
+ <div className="cart-layout">
29
+ <ul className="cart-lines">
30
+ {cart.lines.map((line) => (
31
+ <li key={line.id}>
32
+ <a href={`/${language}/${line.productSlug}`}>
33
+ {line.productName} — {line.variantName}
34
+ </a>
35
+ <p>{formatMoney(line.linePrice, line.currencyCode)}</p>
36
+ <form action={updateCartLine.bind(null, language, line.id)}>
37
+ <label>
38
+ {t("cart.quantity")}
39
+ <input
40
+ name="quantity"
41
+ type="number"
42
+ min="0"
43
+ defaultValue={line.quantity}
44
+ />
45
+ </label>
46
+ <button type="submit">{t("cart.update")}</button>
47
+ </form>
48
+ <form action={removeCartLine.bind(null, language, line.id)}>
49
+ <button type="submit">{t("cart.remove")}</button>
50
+ </form>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ <aside className="cart-summary">
55
+ <dl>
56
+ <div>
57
+ <dt>{t("cart.subtotal")}</dt>
58
+ <dd>{formatMoney(cart.subtotal, cart.currencyCode)}</dd>
59
+ </div>
60
+ <div>
61
+ <dt>{t("cart.shipping")}</dt>
62
+ <dd>{formatMoney(cart.shipping, cart.currencyCode)}</dd>
63
+ </div>
64
+ <div>
65
+ <dt>{t("cart.total")}</dt>
66
+ <dd>{formatMoney(cart.total, cart.currencyCode)}</dd>
67
+ </div>
68
+ </dl>
69
+ <a className="button-link" href={`/${language}/checkout`}>
70
+ {t("cart.checkout")}
71
+ </a>
72
+ </aside>
73
+ </div>
74
+ </>
75
+ )}
76
+ </main>
77
+ );
78
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ import { useActionState, useState } from "react";
4
+
5
+ import type { CommerceLocale } from "../../../lib/commerce";
6
+ import type {
7
+ CheckoutActionState,
8
+ CheckoutShippingMethod,
9
+ } from "../../../lib/checkout";
10
+ import { createTranslator } from "../../../lib/i18n";
11
+
12
+ interface CheckoutFormProps {
13
+ locale: CommerceLocale;
14
+ action: (
15
+ previousState: CheckoutActionState,
16
+ formData: FormData,
17
+ ) => Promise<CheckoutActionState>;
18
+ }
19
+
20
+ export function CheckoutForm({ locale, action }: CheckoutFormProps) {
21
+ const [state, formAction, pending] = useActionState(action, { ok: true });
22
+ const [shippingMethod, setShippingMethod] =
23
+ useState<CheckoutShippingMethod>("pickup");
24
+ const t = createTranslator(locale);
25
+ const courier = shippingMethod === "courier";
26
+
27
+ return (
28
+ <form action={formAction} className="checkout-form">
29
+ <label>
30
+ {t("checkout.email")}
31
+ <input name="emailAddress" type="email" required />
32
+ </label>
33
+ <label>
34
+ {t("checkout.firstName")}
35
+ <input name="firstName" required />
36
+ </label>
37
+ <label>
38
+ {t("checkout.lastName")}
39
+ <input name="lastName" required />
40
+ </label>
41
+ <label>
42
+ {t("checkout.phone")}
43
+ <input name="phoneNumber" type="tel" required />
44
+ </label>
45
+ <fieldset>
46
+ <legend>{t("checkout.shippingMethod")}</legend>
47
+ <label>
48
+ <input
49
+ name="shippingMethodCode"
50
+ type="radio"
51
+ value="pickup"
52
+ checked={shippingMethod === "pickup"}
53
+ onChange={() => setShippingMethod("pickup")}
54
+ />
55
+ {t("checkout.pickup")}
56
+ </label>
57
+ <label>
58
+ <input
59
+ name="shippingMethodCode"
60
+ type="radio"
61
+ value="courier"
62
+ checked={shippingMethod === "courier"}
63
+ onChange={() => setShippingMethod("courier")}
64
+ />
65
+ {t("checkout.courier")}
66
+ </label>
67
+ </fieldset>
68
+ {courier ? (
69
+ <fieldset>
70
+ <legend>{t("checkout.courier")}</legend>
71
+ <label>
72
+ {t("checkout.street")}
73
+ <input name="streetLine1" required />
74
+ </label>
75
+ <label>
76
+ {t("checkout.city")}
77
+ <input name="city" required />
78
+ </label>
79
+ <label>
80
+ {t("checkout.postalCode")}
81
+ <input name="postalCode" required />
82
+ </label>
83
+ <label>
84
+ {t("checkout.country")}
85
+ <select name="countryCode" defaultValue="SK" required>
86
+ <option value="SK">{t("checkout.slovakia")}</option>
87
+ </select>
88
+ </label>
89
+ </fieldset>
90
+ ) : null}
91
+ <p>
92
+ {courier ? t("checkout.courierSummary") : t("checkout.pickupSummary")}
93
+ </p>
94
+ {!state.ok ? (
95
+ <p role="alert">
96
+ {state.errorCode === "PRICE_CHANGED"
97
+ ? t("checkout.priceChanged")
98
+ : state.errorCode === "INSUFFICIENT_STOCK"
99
+ ? t("checkout.insufficientStock")
100
+ : state.message}
101
+ </p>
102
+ ) : null}
103
+ <label>
104
+ <input name="termsAccepted" type="checkbox" required />{" "}
105
+ {t("checkout.terms")}
106
+ </label>
107
+ <button type="submit" disabled={pending}>
108
+ {courier ? t("checkout.submitCourier") : t("checkout.submitPickup")}
109
+ </button>
110
+ </form>
111
+ );
112
+ }
@@ -0,0 +1,44 @@
1
+ import { placeOrder } from "../../../lib/checkout";
2
+ import { getCart } from "../../../lib/cart";
3
+ import { createTranslator, parseLocale } from "../../../lib/i18n";
4
+ import { CheckoutForm } from "./checkout-form";
5
+
6
+ interface CheckoutPageProps {
7
+ params: Promise<{ locale: string }>;
8
+ }
9
+
10
+ export default async function CheckoutPage({ params }: CheckoutPageProps) {
11
+ const { locale } = await params;
12
+ const language = parseLocale(locale);
13
+ const attemptId = crypto.randomUUID();
14
+ const cart = await getCart(language);
15
+ const t = createTranslator(language);
16
+
17
+ return (
18
+ <main className="page checkout-page" lang={language}>
19
+ <div className="page-heading">
20
+ <p className="eyebrow">JarShop starter</p>
21
+ <h1>{t("checkout.title")}</h1>
22
+ </div>
23
+ <CheckoutForm
24
+ locale={language}
25
+ action={placeOrder.bind(
26
+ null,
27
+ language,
28
+ attemptId,
29
+ cart
30
+ ? {
31
+ currencyCode: cart.currencyCode,
32
+ subtotalWithTax: cart.subtotal,
33
+ lines: cart.lines.map((line) => ({
34
+ lineId: line.id,
35
+ quantity: line.quantity,
36
+ linePriceWithTax: line.linePrice,
37
+ })),
38
+ }
39
+ : null,
40
+ )}
41
+ />
42
+ </main>
43
+ );
44
+ }
@@ -0,0 +1,23 @@
1
+ import { createTranslator, parseLocale } from "../../../../lib/i18n";
2
+ import { getOrderConfirmation } from "../../../../lib/confirmation";
3
+
4
+ interface CheckoutSuccessProps {
5
+ params: Promise<{ locale: string }>;
6
+ }
7
+
8
+ export default async function CheckoutSuccess({
9
+ params,
10
+ }: CheckoutSuccessProps) {
11
+ const { locale } = await params;
12
+ const language = parseLocale(locale);
13
+ const t = createTranslator(language);
14
+ const confirmation = await getOrderConfirmation(language);
15
+ return (
16
+ <main lang={language}>
17
+ <h1>{t("success.title")}</h1>
18
+ <p>
19
+ {t("success.orderCode")}: {confirmation?.orderCode ?? "—"}
20
+ </p>
21
+ </main>
22
+ );
23
+ }