@jarwizz/create-jarshop 0.1.1 → 0.1.3

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 (107) hide show
  1. package/README.md +10 -2
  2. package/dist/generator.js +10 -4
  3. package/dist/generator.js.map +1 -1
  4. package/dist/template/AGENTS.md +3 -3
  5. package/dist/template/CONTEXT.md +14 -0
  6. package/dist/template/README.md +25 -3
  7. package/dist/template/docker-compose.yml +5 -0
  8. package/dist/template/docs/smtp-smoke.md +37 -0
  9. package/dist/template/env.example.template +18 -0
  10. package/dist/template/gitignore.template +2 -0
  11. package/dist/template/package.json +19 -6
  12. package/dist/template/pnpm-lock.yaml +2389 -98
  13. package/dist/template/pnpm-workspace.yaml +12 -1
  14. package/dist/template/src/dev-storefront.ts +58 -0
  15. package/dist/template/src/dev.ts +67 -4
  16. package/dist/template/src/email-smoke.ts +27 -0
  17. package/dist/template/src/environment.d.ts +6 -0
  18. package/dist/template/src/migrate.ts +16 -0
  19. package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
  20. package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
  21. package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
  22. package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
  23. package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
  24. package/dist/template/src/plugins/jarshop-checkout/cod-payment.test.ts +44 -0
  25. package/dist/template/src/plugins/jarshop-checkout/cod-payment.ts +28 -0
  26. package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
  27. package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +59 -0
  28. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
  29. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
  30. package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
  31. package/dist/template/src/plugins/jarshop-checkout/index.ts +51 -0
  32. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +870 -0
  33. package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
  34. package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
  35. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
  36. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
  37. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
  38. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
  39. package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
  40. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
  41. package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
  42. package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
  43. package/dist/template/src/seed.ts +171 -0
  44. package/dist/template/src/vendure-config.ts +72 -7
  45. package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
  46. package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
  47. package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
  48. package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
  49. package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
  50. package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
  51. package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
  52. package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
  53. package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
  54. package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
  55. package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
  56. package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
  57. package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
  58. package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
  59. package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
  60. package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
  61. package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
  62. package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
  63. package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
  64. package/dist/template/storefront/app/[locale]/checkout/confirmation/route.ts +21 -0
  65. package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
  66. package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +28 -0
  67. package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
  68. package/dist/template/storefront/app/[locale]/page.tsx +12 -13
  69. package/dist/template/storefront/app/globals.css +372 -4
  70. package/dist/template/storefront/app/layout.tsx +13 -0
  71. package/dist/template/storefront/eslint.config.mjs +8 -1
  72. package/dist/template/storefront/gitignore.template +1 -0
  73. package/dist/template/storefront/lib/account-security.test.ts +74 -0
  74. package/dist/template/storefront/lib/account-security.ts +81 -0
  75. package/dist/template/storefront/lib/account.ts +366 -0
  76. package/dist/template/storefront/lib/cart-contract.ts +52 -0
  77. package/dist/template/storefront/lib/cart.test.ts +30 -0
  78. package/dist/template/storefront/lib/cart.ts +130 -0
  79. package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
  80. package/dist/template/storefront/lib/checkout-contract.ts +16 -0
  81. package/dist/template/storefront/lib/checkout.ts +149 -0
  82. package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
  83. package/dist/template/storefront/lib/commerce-session.ts +85 -0
  84. package/dist/template/storefront/lib/commerce.ts +129 -4
  85. package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
  86. package/dist/template/storefront/lib/confirmation.ts +95 -0
  87. package/dist/template/storefront/lib/form-values.test.ts +14 -0
  88. package/dist/template/storefront/lib/form-values.ts +7 -0
  89. package/dist/template/storefront/lib/i18n.test.ts +50 -0
  90. package/dist/template/storefront/lib/i18n.ts +44 -0
  91. package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
  92. package/dist/template/storefront/lib/password-reset-token.ts +17 -0
  93. package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
  94. package/dist/template/storefront/messages/en.json +89 -0
  95. package/dist/template/storefront/messages/sk.json +89 -0
  96. package/dist/template/storefront/next.config.ts +3 -1
  97. package/dist/template/storefront/package.json +11 -4
  98. package/dist/template/storefront/vitest.config.ts +8 -0
  99. package/dist/template/tsconfig.json +2 -1
  100. package/dist/template/turbo.json +8 -0
  101. package/dist/template/vite.config.mts +4 -1
  102. package/dist/template-manifest.json +1 -1
  103. package/dist/template.js +3 -0
  104. package/dist/template.js.map +1 -1
  105. package/package.json +1 -1
  106. package/dist/template/storefront/pnpm-lock.yaml +0 -3562
  107. package/dist/template/storefront/pnpm-workspace.yaml +0 -26
@@ -0,0 +1,366 @@
1
+ "use server";
2
+
3
+ import type {
4
+ AccountProfile,
5
+ AccountSummary,
6
+ RegisterAccountInput,
7
+ SessionStore,
8
+ } from "@jarwizz/commerce-sdk/types";
9
+ import { cookies, headers } from "next/headers";
10
+ import { revalidatePath } from "next/cache";
11
+ import { redirect } from "next/navigation";
12
+
13
+ import type { CommerceLocale } from "./commerce";
14
+ import {
15
+ createAccountAddress,
16
+ changeAccountPassword,
17
+ deleteAccountAddress,
18
+ getAccountProfile,
19
+ getActiveAccount,
20
+ loginAccount,
21
+ logoutAccount,
22
+ registerAccount,
23
+ refreshAccountRegistration,
24
+ requestAccountPasswordReset,
25
+ resetAccountPassword,
26
+ updateAccountAddress,
27
+ updateAccountProfile,
28
+ verifyAccountRegistration,
29
+ } from "./commerce";
30
+ import { createCommerceSessionStore } from "./commerce-session";
31
+ import { createAccountActionGuard } from "./account-security";
32
+ import { formPassword, formText } from "./form-values";
33
+ import {
34
+ PASSWORD_RESET_TOKEN_COOKIE,
35
+ passwordResetToken,
36
+ } from "./password-reset-token";
37
+ import { valkeyAccountRateLimitStore } from "./valkey-rate-limit";
38
+
39
+ export type LoginActionState =
40
+ | { readonly status: "idle" }
41
+ | {
42
+ readonly status: "error";
43
+ readonly reason: "invalidCredentials" | "temporaryFailure";
44
+ };
45
+
46
+ type AccountFailureReason =
47
+ | "invalidCredentials"
48
+ | "passwordInvalid"
49
+ | "passwordMismatch"
50
+ | "invalidToken"
51
+ | "temporaryFailure";
52
+
53
+ export type AccountActionState =
54
+ | { readonly status: "idle" }
55
+ | { readonly status: "success" }
56
+ | {
57
+ readonly status: "error";
58
+ readonly reason: AccountFailureReason;
59
+ };
60
+
61
+ async function sessionStore() {
62
+ return createCommerceSessionStore(await cookies());
63
+ }
64
+
65
+ const accountActionGuard = createAccountActionGuard(
66
+ valkeyAccountRateLimitStore,
67
+ );
68
+
69
+ async function accountActionAllowedForHeaders(
70
+ scope: string,
71
+ subject: string,
72
+ requestHeaders: Pick<Headers, "get">,
73
+ ): Promise<boolean> {
74
+ return (
75
+ await accountActionGuard.check({
76
+ scope,
77
+ subject,
78
+ origin: requestHeaders.get("origin"),
79
+ host:
80
+ requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
81
+ clientAddress:
82
+ requestHeaders.get("x-forwarded-for")?.split(",")[0] ?? null,
83
+ now: Date.now(),
84
+ })
85
+ ).allowed;
86
+ }
87
+
88
+ async function accountActionAllowed(
89
+ scope: string,
90
+ subject: string,
91
+ ): Promise<boolean> {
92
+ return accountActionAllowedForHeaders(scope, subject, await headers());
93
+ }
94
+
95
+ function accountFailure(reason: AccountFailureReason): AccountActionState {
96
+ return { status: "error", reason };
97
+ }
98
+
99
+ function failureReason(code: string): AccountFailureReason {
100
+ if (code === "PASSWORD_INVALID") return "passwordInvalid";
101
+ if (code === "INVALID_CREDENTIALS") return "invalidCredentials";
102
+ if (code === "NOT_VERIFIED" || code === "RESET_TOKEN_INVALID") {
103
+ return "invalidToken";
104
+ }
105
+ return "temporaryFailure";
106
+ }
107
+
108
+ export async function getSignedInAccount(
109
+ locale: CommerceLocale,
110
+ ): Promise<AccountSummary | null> {
111
+ const result = await getActiveAccount(locale, await sessionStore());
112
+ return result.ok ? result.data : null;
113
+ }
114
+
115
+ export async function getSignedInAccountProfile(
116
+ locale: CommerceLocale,
117
+ orderTake = 10,
118
+ ): Promise<AccountProfile | null> {
119
+ const result = await getAccountProfile(locale, await sessionStore(), {
120
+ take: orderTake,
121
+ });
122
+ return result.ok ? result.data : null;
123
+ }
124
+
125
+ export async function login(
126
+ locale: CommerceLocale,
127
+ _previousState: LoginActionState,
128
+ formData: FormData,
129
+ ): Promise<LoginActionState> {
130
+ const emailAddress = formText(formData, "emailAddress");
131
+ if (!(await accountActionAllowed("login", emailAddress))) {
132
+ return { status: "error", reason: "temporaryFailure" };
133
+ }
134
+ const result = await loginAccount(locale, await sessionStore(), {
135
+ emailAddress,
136
+ password: formPassword(formData, "password"),
137
+ });
138
+ if (!result.ok) {
139
+ return {
140
+ status: "error",
141
+ reason:
142
+ result.error.code === "INVALID_CREDENTIALS"
143
+ ? "invalidCredentials"
144
+ : "temporaryFailure",
145
+ };
146
+ }
147
+ redirect(`/${locale}/account`);
148
+ }
149
+
150
+ export async function register(
151
+ locale: CommerceLocale,
152
+ _previousState: AccountActionState,
153
+ formData: FormData,
154
+ ): Promise<AccountActionState> {
155
+ const input: RegisterAccountInput = {
156
+ emailAddress: formText(formData, "emailAddress"),
157
+ firstName: formText(formData, "firstName"),
158
+ lastName: formText(formData, "lastName"),
159
+ password: formPassword(formData, "password"),
160
+ };
161
+ const phoneNumber = formText(formData, "phoneNumber");
162
+ if (phoneNumber) input.phoneNumber = phoneNumber;
163
+ if (
164
+ !input.emailAddress ||
165
+ !input.firstName ||
166
+ !input.lastName ||
167
+ !input.password
168
+ ) {
169
+ return accountFailure("temporaryFailure");
170
+ }
171
+ if (!(await accountActionAllowed("register", input.emailAddress))) {
172
+ return accountFailure("temporaryFailure");
173
+ }
174
+ const result = await registerAccount(locale, input);
175
+ return result.ok
176
+ ? { status: "success" }
177
+ : accountFailure(failureReason(result.error.code));
178
+ }
179
+
180
+ export async function completeAccountVerification(
181
+ locale: CommerceLocale,
182
+ session: SessionStore,
183
+ token: string,
184
+ requestHeaders: Pick<Headers, "get">,
185
+ ): Promise<boolean> {
186
+ if (
187
+ !(await accountActionAllowedForHeaders("verify", token, requestHeaders))
188
+ ) {
189
+ return false;
190
+ }
191
+ const result = await verifyAccountRegistration(locale, session, token);
192
+ return result.ok;
193
+ }
194
+
195
+ export async function requestPasswordReset(
196
+ locale: CommerceLocale,
197
+ _previousState: AccountActionState,
198
+ formData: FormData,
199
+ ): Promise<AccountActionState> {
200
+ const emailAddress = formText(formData, "emailAddress");
201
+ if (!(await accountActionAllowed("password-reset", emailAddress))) {
202
+ return { status: "success" };
203
+ }
204
+ const result = await requestAccountPasswordReset(locale, emailAddress);
205
+ // Vendure intentionally makes both existing and unknown addresses look the
206
+ // same. Preserve that response at the UI boundary to prevent enumeration.
207
+ return result.ok ? { status: "success" } : accountFailure("temporaryFailure");
208
+ }
209
+
210
+ export async function refreshVerification(
211
+ locale: CommerceLocale,
212
+ _previousState: AccountActionState,
213
+ formData: FormData,
214
+ ): Promise<AccountActionState> {
215
+ const emailAddress = formText(formData, "emailAddress");
216
+ if (!(await accountActionAllowed("refresh-verification", emailAddress))) {
217
+ return { status: "success" };
218
+ }
219
+ await refreshAccountRegistration(locale, emailAddress);
220
+ // The browser response stays neutral for unknown, verified and unverified
221
+ // addresses. Only the mailbox owner learns whether a new token was sent.
222
+ return { status: "success" };
223
+ }
224
+
225
+ export async function resetPassword(
226
+ locale: CommerceLocale,
227
+ _previousState: AccountActionState,
228
+ formData: FormData,
229
+ ): Promise<AccountActionState> {
230
+ const cookieStore = await cookies();
231
+ const token = passwordResetToken(cookieStore);
232
+ const password = formPassword(formData, "password");
233
+ if (password !== formPassword(formData, "confirmPassword")) {
234
+ return accountFailure("passwordMismatch");
235
+ }
236
+ if (!token) return accountFailure("invalidToken");
237
+ if (!(await accountActionAllowed("reset-password", token))) {
238
+ return accountFailure("temporaryFailure");
239
+ }
240
+ const result = await resetAccountPassword(locale, await sessionStore(), {
241
+ token,
242
+ password,
243
+ });
244
+ if (!result.ok) {
245
+ if (result.error.code === "RESET_TOKEN_INVALID") {
246
+ cookieStore.delete(PASSWORD_RESET_TOKEN_COOKIE);
247
+ }
248
+ return accountFailure(failureReason(result.error.code));
249
+ }
250
+ cookieStore.delete(PASSWORD_RESET_TOKEN_COOKIE);
251
+ redirect(`/${locale}/account`);
252
+ }
253
+
254
+ export async function updateProfile(
255
+ locale: CommerceLocale,
256
+ _previousState: AccountActionState,
257
+ formData: FormData,
258
+ ): Promise<AccountActionState> {
259
+ if (!(await accountActionAllowed("update-profile", "authenticated-user"))) {
260
+ return accountFailure("temporaryFailure");
261
+ }
262
+ const result = await updateAccountProfile(locale, await sessionStore(), {
263
+ firstName: formText(formData, "firstName"),
264
+ lastName: formText(formData, "lastName"),
265
+ phoneNumber: formText(formData, "phoneNumber") || undefined,
266
+ });
267
+ if (!result.ok) return accountFailure("temporaryFailure");
268
+ revalidatePath(`/${locale}/account`);
269
+ return { status: "success" };
270
+ }
271
+
272
+ export async function changePassword(
273
+ locale: CommerceLocale,
274
+ _previousState: AccountActionState,
275
+ formData: FormData,
276
+ ): Promise<AccountActionState> {
277
+ if (!(await accountActionAllowed("change-password", "authenticated-user"))) {
278
+ return accountFailure("temporaryFailure");
279
+ }
280
+ const result = await changeAccountPassword(locale, await sessionStore(), {
281
+ currentPassword: formPassword(formData, "currentPassword"),
282
+ newPassword: formPassword(formData, "newPassword"),
283
+ });
284
+ return result.ok
285
+ ? { status: "success" }
286
+ : accountFailure(failureReason(result.error.code));
287
+ }
288
+
289
+ export async function addAddress(
290
+ locale: CommerceLocale,
291
+ _previousState: AccountActionState,
292
+ formData: FormData,
293
+ ): Promise<AccountActionState> {
294
+ if (!(await accountActionAllowed("add-address", "authenticated-user"))) {
295
+ return accountFailure("temporaryFailure");
296
+ }
297
+ const result = await createAccountAddress(locale, await sessionStore(), {
298
+ fullName: formText(formData, "fullName") || undefined,
299
+ streetLine1: formText(formData, "streetLine1"),
300
+ city: formText(formData, "city") || undefined,
301
+ postalCode: formText(formData, "postalCode") || undefined,
302
+ countryCode: formText(formData, "countryCode"),
303
+ phoneNumber: formText(formData, "phoneNumber") || undefined,
304
+ defaultShippingAddress: formData.get("defaultShippingAddress") === "on",
305
+ defaultBillingAddress: formData.get("defaultBillingAddress") === "on",
306
+ });
307
+ if (!result.ok) return accountFailure("temporaryFailure");
308
+ revalidatePath(`/${locale}/account`);
309
+ return { status: "success" };
310
+ }
311
+
312
+ export async function updateAddress(
313
+ locale: CommerceLocale,
314
+ addressId: string,
315
+ _previousState: AccountActionState,
316
+ formData: FormData,
317
+ ): Promise<AccountActionState> {
318
+ if (!(await accountActionAllowed("update-address", "authenticated-user"))) {
319
+ return accountFailure("temporaryFailure");
320
+ }
321
+ const result = await updateAccountAddress(locale, await sessionStore(), {
322
+ id: addressId,
323
+ fullName: formText(formData, "fullName") || undefined,
324
+ streetLine1: formText(formData, "streetLine1") || undefined,
325
+ city: formText(formData, "city") || undefined,
326
+ postalCode: formText(formData, "postalCode") || undefined,
327
+ countryCode: formText(formData, "countryCode") || undefined,
328
+ phoneNumber: formText(formData, "phoneNumber") || undefined,
329
+ defaultShippingAddress: formData.get("defaultShippingAddress") === "on",
330
+ defaultBillingAddress: formData.get("defaultBillingAddress") === "on",
331
+ });
332
+ if (!result.ok) return accountFailure("temporaryFailure");
333
+ revalidatePath(`/${locale}/account`);
334
+ return { status: "success" };
335
+ }
336
+
337
+ export async function removeAddress(
338
+ locale: CommerceLocale,
339
+ addressId: string,
340
+ _previousState: AccountActionState,
341
+ _formData: FormData,
342
+ ): Promise<AccountActionState> {
343
+ if (!(await accountActionAllowed("remove-address", "authenticated-user"))) {
344
+ return accountFailure("temporaryFailure");
345
+ }
346
+ const result = await deleteAccountAddress(
347
+ locale,
348
+ await sessionStore(),
349
+ addressId,
350
+ );
351
+ if (!result.ok) return accountFailure("temporaryFailure");
352
+ revalidatePath(`/${locale}/account`);
353
+ return { status: "success" };
354
+ }
355
+
356
+ export async function logout(locale: CommerceLocale): Promise<void> {
357
+ if (!(await accountActionAllowed("logout", "authenticated-user"))) {
358
+ redirect(`/${locale}`);
359
+ }
360
+ const store = await sessionStore();
361
+ const result = await logoutAccount(locale, store);
362
+ // If the backend is temporarily unreachable, removing the browser-bound
363
+ // opaque token still gives the customer a safe local sign-out.
364
+ if (!result.ok) store.set(undefined);
365
+ redirect(`/${locale}`);
366
+ }
@@ -0,0 +1,52 @@
1
+ export interface CartLine {
2
+ id: string;
3
+ productSlug: string;
4
+ productName: string;
5
+ variantName: string;
6
+ quantity: number;
7
+ linePrice: number;
8
+ currencyCode: string;
9
+ }
10
+
11
+ export interface CartView {
12
+ lines: CartLine[];
13
+ subtotal: number;
14
+ shipping: number;
15
+ total: number;
16
+ currencyCode: string;
17
+ }
18
+
19
+ interface ShopApiCart {
20
+ lines: Array<{
21
+ id: string | number;
22
+ quantity: number;
23
+ discountedLinePriceWithTax: number;
24
+ productVariant: { name: string; product: { slug: string; name: string } };
25
+ }>;
26
+ subTotalWithTax: number;
27
+ shippingWithTax: number;
28
+ totalWithTax: number;
29
+ currencyCode: string;
30
+ }
31
+
32
+ export function mapCart(order: ShopApiCart): CartView {
33
+ return {
34
+ lines: order.lines.map((line) => ({
35
+ // Vendure's Shop API serializes the built-in numeric ID strategy as a
36
+ // JSON number. The checkout contract treats identifiers as strings, so
37
+ // normalize at the Commerce Facade boundary before a browser snapshot is
38
+ // sent back to the server.
39
+ id: String(line.id),
40
+ productSlug: line.productVariant.product.slug,
41
+ productName: line.productVariant.product.name,
42
+ variantName: line.productVariant.name,
43
+ quantity: line.quantity,
44
+ linePrice: line.discountedLinePriceWithTax,
45
+ currencyCode: order.currencyCode,
46
+ })),
47
+ subtotal: order.subTotalWithTax,
48
+ shipping: order.shippingWithTax,
49
+ total: order.totalWithTax,
50
+ currencyCode: order.currencyCode,
51
+ };
52
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mapCart } from "./cart-contract";
4
+
5
+ describe("mapCart", () => {
6
+ it("normalizes numeric Shop API line IDs for the checkout contract", () => {
7
+ const cart = mapCart({
8
+ currencyCode: "EUR",
9
+ subTotalWithTax: 1990,
10
+ shippingWithTax: 0,
11
+ totalWithTax: 1990,
12
+ lines: [
13
+ {
14
+ id: 1,
15
+ quantity: 1,
16
+ discountedLinePriceWithTax: 1990,
17
+ productVariant: {
18
+ name: "JarShop demo variant",
19
+ product: {
20
+ name: "JarShop demo product",
21
+ slug: "jarshop-demo-product",
22
+ },
23
+ },
24
+ },
25
+ ],
26
+ });
27
+
28
+ expect(cart.lines[0]?.id).toBe("1");
29
+ });
30
+ });
@@ -0,0 +1,130 @@
1
+ "use server";
2
+
3
+ import { cookies } from "next/headers";
4
+ import { revalidatePath } from "next/cache";
5
+
6
+ import type { CommerceLocale } from "./commerce";
7
+ import { mapCart, type CartView } from "./cart-contract";
8
+ import {
9
+ commerceSessionAuthorization,
10
+ persistCommerceSession,
11
+ } from "./commerce-session";
12
+
13
+ export type { CartView } from "./cart-contract";
14
+
15
+ export type AddToCartState =
16
+ | { readonly status: "idle" }
17
+ | { readonly status: "added" }
18
+ | { readonly status: "error"; readonly message: string };
19
+
20
+ interface GraphqlResponse<T> {
21
+ data?: T;
22
+ errors?: Array<{ message: string }>;
23
+ }
24
+
25
+ const CART_FIELDS = `
26
+ lines {
27
+ id quantity discountedLinePriceWithTax
28
+ productVariant { name product { slug name } }
29
+ }
30
+ subTotalWithTax shippingWithTax totalWithTax currencyCode
31
+ `;
32
+
33
+ async function request<T>(
34
+ locale: CommerceLocale,
35
+ query: string,
36
+ variables?: Record<string, unknown>,
37
+ ): Promise<T> {
38
+ const store = await cookies();
39
+ const endpoint = new URL(
40
+ process.env.JARSHOP_COMMERCE_ENDPOINT ?? "http://localhost:3000/shop-api",
41
+ );
42
+ endpoint.searchParams.set("languageCode", locale);
43
+ const authorization = commerceSessionAuthorization(store);
44
+ const response = await fetch(endpoint, {
45
+ method: "POST",
46
+ headers: {
47
+ "content-type": "application/json",
48
+ ...(process.env.JARSHOP_CHANNEL_TOKEN
49
+ ? { "vendure-token": process.env.JARSHOP_CHANNEL_TOKEN }
50
+ : {}),
51
+ ...(authorization ? { authorization } : {}),
52
+ },
53
+ body: JSON.stringify({ query, variables }),
54
+ cache: "no-store",
55
+ });
56
+ persistCommerceSession(store, response.headers);
57
+ if (!response.ok) throw new Error(`Commerce API returned ${response.status}`);
58
+ const body = (await response.json()) as GraphqlResponse<T>;
59
+ if (body.errors?.length)
60
+ throw new Error(body.errors[0]?.message ?? "Commerce request failed");
61
+ if (!body.data) throw new Error("Commerce API returned no data");
62
+ return body.data;
63
+ }
64
+
65
+ export async function getCart(
66
+ locale: CommerceLocale,
67
+ ): Promise<CartView | null> {
68
+ try {
69
+ const result = await request<{
70
+ activeOrder: Parameters<typeof mapCart>[0] | null;
71
+ }>(locale, `query ActiveCart { activeOrder { ${CART_FIELDS} } }`);
72
+ return result.activeOrder ? mapCart(result.activeOrder) : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export async function addToCart(
79
+ locale: CommerceLocale,
80
+ variantId: string,
81
+ _previousState: AddToCartState,
82
+ formData: FormData,
83
+ ): Promise<AddToCartState> {
84
+ try {
85
+ await request(
86
+ locale,
87
+ `mutation Add($variantId: ID!, $quantity: Int!) { addItemToOrder(productVariantId: $variantId, quantity: $quantity) { __typename } }`,
88
+ {
89
+ variantId,
90
+ quantity: Number(formData.get("quantity") ?? 1),
91
+ },
92
+ );
93
+ revalidatePath(`/${locale}`);
94
+ revalidatePath(`/${locale}/cart`);
95
+ return { status: "added" };
96
+ } catch (error) {
97
+ return {
98
+ status: "error",
99
+ message: error instanceof Error ? error.message : "Unable to add item",
100
+ };
101
+ }
102
+ }
103
+
104
+ export async function updateCartLine(
105
+ locale: CommerceLocale,
106
+ lineId: string,
107
+ formData: FormData,
108
+ ): Promise<void> {
109
+ await request(
110
+ locale,
111
+ `mutation Update($lineId: ID!, $quantity: Int!) { adjustOrderLine(orderLineId: $lineId, quantity: $quantity) { __typename } }`,
112
+ {
113
+ lineId,
114
+ quantity: Number(formData.get("quantity")),
115
+ },
116
+ );
117
+ revalidatePath(`/${locale}/cart`);
118
+ }
119
+
120
+ export async function removeCartLine(
121
+ locale: CommerceLocale,
122
+ lineId: string,
123
+ ): Promise<void> {
124
+ await request(
125
+ locale,
126
+ `mutation Remove($lineId: ID!) { removeOrderLine(orderLineId: $lineId) { __typename } }`,
127
+ { lineId },
128
+ );
129
+ revalidatePath(`/${locale}/cart`);
130
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { PLACE_JARSHOP_ORDER_MUTATION } from "./checkout-contract";
4
+
5
+ describe("checkout GraphQL contract", () => {
6
+ it("selects every field required by the storefront success hand-off", () => {
7
+ for (const field of [
8
+ "success",
9
+ "orderCode",
10
+ "errorCode",
11
+ "message",
12
+ "affectedLineIds",
13
+ "confirmationToken",
14
+ ]) {
15
+ expect(PLACE_JARSHOP_ORDER_MUTATION).toMatch(
16
+ new RegExp(`\\b${field}\\b`, "u"),
17
+ );
18
+ }
19
+ });
20
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The confirmation token is required by the success-route hand-off. Keep this
3
+ * operation separate so its selected response fields are regression-tested.
4
+ */
5
+ export const PLACE_JARSHOP_ORDER_MUTATION = `
6
+ mutation Place($input: JarShopPlaceOrderInput!) {
7
+ placeJarShopOrder(input: $input) {
8
+ success
9
+ orderCode
10
+ errorCode
11
+ message
12
+ affectedLineIds
13
+ confirmationToken
14
+ }
15
+ }
16
+ `;