@forgecart/cli 1.1.0 → 2.1.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.
Files changed (181) hide show
  1. package/dist/bin/forgecart.d.ts +2 -0
  2. package/dist/bin/forgecart.js +4 -0
  3. package/dist/bin/forgecart.js.map +1 -0
  4. package/dist/src/cli.d.ts +14 -1
  5. package/dist/src/cli.js +30 -15
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/commands/init.d.ts +39 -2
  8. package/dist/src/commands/init.js +173 -301
  9. package/dist/src/commands/init.js.map +1 -1
  10. package/dist/src/commands/placeholders.d.ts +19 -0
  11. package/dist/src/commands/placeholders.js +109 -0
  12. package/dist/src/commands/placeholders.js.map +1 -0
  13. package/dist/src/output.d.ts +11 -0
  14. package/dist/src/output.js +16 -0
  15. package/dist/src/output.js.map +1 -0
  16. package/package.json +21 -11
  17. package/templates/storefront/.env.example +12 -0
  18. package/templates/storefront/.forgecartignore +2 -0
  19. package/templates/storefront/Procfile +1 -0
  20. package/templates/storefront/README.md +87 -0
  21. package/templates/storefront/next.config.js +14 -0
  22. package/templates/storefront/package.json +25 -0
  23. package/templates/storefront/postcss.config.js +6 -0
  24. package/templates/storefront/src/app/cart/page.tsx +88 -0
  25. package/templates/storefront/src/app/globals.css +13 -0
  26. package/templates/storefront/src/app/layout.tsx +32 -0
  27. package/templates/storefront/src/app/page.tsx +49 -0
  28. package/templates/storefront/src/app/products/[slug]/not-found.tsx +20 -0
  29. package/templates/storefront/src/app/products/[slug]/page.tsx +61 -0
  30. package/templates/storefront/src/app/products/page.tsx +27 -0
  31. package/templates/storefront/src/components/Header.tsx +47 -0
  32. package/templates/storefront/src/components/ProductCard.tsx +40 -0
  33. package/templates/storefront/src/components/ProductPurchase.tsx +91 -0
  34. package/templates/storefront/src/lib/cart-actions.ts +148 -0
  35. package/templates/storefront/src/lib/cart-context.tsx +88 -0
  36. package/templates/storefront/src/lib/forgecart.ts +187 -0
  37. package/{dist/templates/storefront-additions → templates/storefront}/tailwind.config.js +1 -1
  38. package/templates/storefront/tsconfig.json +23 -0
  39. package/LICENSE +0 -21
  40. package/README.md +0 -179
  41. package/bin/forgecart.js +0 -3
  42. package/dist/package.json +0 -50
  43. package/dist/src/cli.d.ts.map +0 -1
  44. package/dist/src/commands/delete.d.ts +0 -3
  45. package/dist/src/commands/delete.d.ts.map +0 -1
  46. package/dist/src/commands/delete.js +0 -60
  47. package/dist/src/commands/delete.js.map +0 -1
  48. package/dist/src/commands/deploy.d.ts +0 -3
  49. package/dist/src/commands/deploy.d.ts.map +0 -1
  50. package/dist/src/commands/deploy.js +0 -243
  51. package/dist/src/commands/deploy.js.map +0 -1
  52. package/dist/src/commands/env.d.ts +0 -3
  53. package/dist/src/commands/env.d.ts.map +0 -1
  54. package/dist/src/commands/env.js +0 -158
  55. package/dist/src/commands/env.js.map +0 -1
  56. package/dist/src/commands/init.d.ts.map +0 -1
  57. package/dist/src/commands/list.d.ts +0 -3
  58. package/dist/src/commands/list.d.ts.map +0 -1
  59. package/dist/src/commands/list.js +0 -65
  60. package/dist/src/commands/list.js.map +0 -1
  61. package/dist/src/commands/login.d.ts +0 -3
  62. package/dist/src/commands/login.d.ts.map +0 -1
  63. package/dist/src/commands/login.js +0 -120
  64. package/dist/src/commands/login.js.map +0 -1
  65. package/dist/src/commands/logs.d.ts +0 -3
  66. package/dist/src/commands/logs.d.ts.map +0 -1
  67. package/dist/src/commands/logs.js +0 -88
  68. package/dist/src/commands/logs.js.map +0 -1
  69. package/dist/src/commands/scale.d.ts +0 -3
  70. package/dist/src/commands/scale.d.ts.map +0 -1
  71. package/dist/src/commands/scale.js +0 -49
  72. package/dist/src/commands/scale.js.map +0 -1
  73. package/dist/src/commands/status.d.ts +0 -3
  74. package/dist/src/commands/status.d.ts.map +0 -1
  75. package/dist/src/commands/status.js +0 -90
  76. package/dist/src/commands/status.js.map +0 -1
  77. package/dist/src/commands/whoami.d.ts +0 -3
  78. package/dist/src/commands/whoami.d.ts.map +0 -1
  79. package/dist/src/commands/whoami.js +0 -59
  80. package/dist/src/commands/whoami.js.map +0 -1
  81. package/dist/src/index.d.ts +0 -3
  82. package/dist/src/index.d.ts.map +0 -1
  83. package/dist/src/index.js +0 -5
  84. package/dist/src/index.js.map +0 -1
  85. package/dist/src/lib/api-client.d.ts +0 -34
  86. package/dist/src/lib/api-client.d.ts.map +0 -1
  87. package/dist/src/lib/api-client.js +0 -141
  88. package/dist/src/lib/api-client.js.map +0 -1
  89. package/dist/src/lib/archive.d.ts +0 -9
  90. package/dist/src/lib/archive.d.ts.map +0 -1
  91. package/dist/src/lib/archive.js +0 -118
  92. package/dist/src/lib/archive.js.map +0 -1
  93. package/dist/src/lib/config-updater.d.ts +0 -10
  94. package/dist/src/lib/config-updater.d.ts.map +0 -1
  95. package/dist/src/lib/config-updater.js +0 -34
  96. package/dist/src/lib/config-updater.js.map +0 -1
  97. package/dist/src/lib/config.d.ts +0 -53
  98. package/dist/src/lib/config.d.ts.map +0 -1
  99. package/dist/src/lib/config.js +0 -162
  100. package/dist/src/lib/config.js.map +0 -1
  101. package/dist/src/lib/env-sync.d.ts +0 -44
  102. package/dist/src/lib/env-sync.d.ts.map +0 -1
  103. package/dist/src/lib/env-sync.js +0 -207
  104. package/dist/src/lib/env-sync.js.map +0 -1
  105. package/dist/src/lib/logger.d.ts +0 -15
  106. package/dist/src/lib/logger.d.ts.map +0 -1
  107. package/dist/src/lib/logger.js +0 -54
  108. package/dist/src/lib/logger.js.map +0 -1
  109. package/dist/src/lib/nx-generator.d.ts +0 -22
  110. package/dist/src/lib/nx-generator.d.ts.map +0 -1
  111. package/dist/src/lib/nx-generator.js +0 -345
  112. package/dist/src/lib/nx-generator.js.map +0 -1
  113. package/dist/src/lib/standalone.d.ts +0 -28
  114. package/dist/src/lib/standalone.d.ts.map +0 -1
  115. package/dist/src/lib/standalone.js +0 -107
  116. package/dist/src/lib/standalone.js.map +0 -1
  117. package/dist/src/types/index.d.ts +0 -80
  118. package/dist/src/types/index.d.ts.map +0 -1
  119. package/dist/src/types/index.js +0 -14
  120. package/dist/src/types/index.js.map +0 -1
  121. package/dist/src/utils/spinner.d.ts +0 -16
  122. package/dist/src/utils/spinner.d.ts.map +0 -1
  123. package/dist/src/utils/spinner.js +0 -88
  124. package/dist/src/utils/spinner.js.map +0 -1
  125. package/dist/src/utils/validation.d.ts +0 -12
  126. package/dist/src/utils/validation.d.ts.map +0 -1
  127. package/dist/src/utils/validation.js +0 -82
  128. package/dist/src/utils/validation.js.map +0 -1
  129. package/dist/templates/application-additions/.forgecartignore +0 -37
  130. package/dist/templates/application-additions/Procfile +0 -1
  131. package/dist/templates/application-additions/src/app/app.module.ts +0 -16
  132. package/dist/templates/application-additions/src/services/forgecart.service.ts +0 -62
  133. package/dist/templates/application-additions/src/services/index.ts +0 -2
  134. package/dist/templates/application-additions/src/services/product-event-listener.service.ts +0 -150
  135. package/dist/templates/application-e2e-additions/src/support/global-setup.ts +0 -16
  136. package/dist/templates/application-e2e-additions/src/support/test-setup.ts +0 -9
  137. package/dist/templates/storefront-additions/.forgecartignore +0 -64
  138. package/dist/templates/storefront-additions/Procfile +0 -1
  139. package/dist/templates/storefront-additions/package.json +0 -11
  140. package/dist/templates/storefront-additions/postcss.config.js +0 -5
  141. package/dist/templates/storefront-additions/src/app/cart/page.tsx +0 -77
  142. package/dist/templates/storefront-additions/src/app/global.css +0 -1
  143. package/dist/templates/storefront-additions/src/app/layout.tsx +0 -25
  144. package/dist/templates/storefront-additions/src/app/page.tsx +0 -43
  145. package/dist/templates/storefront-additions/src/components/AddToCartButton.tsx +0 -29
  146. package/dist/templates/storefront-additions/src/components/CartItem.tsx +0 -82
  147. package/dist/templates/storefront-additions/src/components/Header.tsx +0 -42
  148. package/dist/templates/storefront-additions/src/components/ProductCard.tsx +0 -57
  149. package/dist/templates/storefront-additions/src/components/QuantitySelector.tsx +0 -35
  150. package/dist/templates/storefront-additions/src/context/CartContext.tsx +0 -158
  151. package/dist/templates/storefront-additions/src/lib/forgecart.ts +0 -22
  152. package/dist/templates/storefront-e2e-additions/playwright.config.ts +0 -26
  153. package/dist/templates/storefront-e2e-additions/src/example.spec.ts +0 -11
  154. package/dist/templates/workspace/.nxignore +0 -2
  155. package/templates/application-additions/.forgecartignore +0 -37
  156. package/templates/application-additions/Procfile +0 -1
  157. package/templates/application-additions/src/app/app.module.ts +0 -16
  158. package/templates/application-additions/src/services/forgecart.service.ts +0 -62
  159. package/templates/application-additions/src/services/index.ts +0 -2
  160. package/templates/application-additions/src/services/product-event-listener.service.ts +0 -150
  161. package/templates/application-e2e-additions/src/support/global-setup.ts +0 -16
  162. package/templates/application-e2e-additions/src/support/test-setup.ts +0 -9
  163. package/templates/storefront-additions/.forgecartignore +0 -64
  164. package/templates/storefront-additions/Procfile +0 -1
  165. package/templates/storefront-additions/package.json +0 -11
  166. package/templates/storefront-additions/postcss.config.js +0 -5
  167. package/templates/storefront-additions/src/app/cart/page.tsx +0 -77
  168. package/templates/storefront-additions/src/app/global.css +0 -1
  169. package/templates/storefront-additions/src/app/layout.tsx +0 -25
  170. package/templates/storefront-additions/src/app/page.tsx +0 -43
  171. package/templates/storefront-additions/src/components/AddToCartButton.tsx +0 -29
  172. package/templates/storefront-additions/src/components/CartItem.tsx +0 -82
  173. package/templates/storefront-additions/src/components/Header.tsx +0 -42
  174. package/templates/storefront-additions/src/components/ProductCard.tsx +0 -57
  175. package/templates/storefront-additions/src/components/QuantitySelector.tsx +0 -35
  176. package/templates/storefront-additions/src/context/CartContext.tsx +0 -158
  177. package/templates/storefront-additions/src/lib/forgecart.ts +0 -22
  178. package/templates/storefront-additions/tailwind.config.js +0 -8
  179. package/templates/storefront-e2e-additions/playwright.config.ts +0 -26
  180. package/templates/storefront-e2e-additions/src/example.spec.ts +0 -11
  181. package/templates/workspace/.nxignore +0 -2
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ import { useCart } from '../lib/cart-context';
7
+
8
+ /** Top navigation: Home / Products / Cart, with a live cart item count. */
9
+ export function Header() {
10
+ const { itemCount } = useCart();
11
+
12
+ // The count is seeded from the server's `initialCart`, but a cookie set after
13
+ // that render can leave the client with a different count — rendering the
14
+ // badge on the server would then hydration-mismatch. Gate it on a
15
+ // post-hydration mount flag so the server emits no badge; it appears with the
16
+ // live count only once the client takes over.
17
+ const [mounted, setMounted] = useState(false);
18
+ useEffect(() => {
19
+ setMounted(true);
20
+ }, []);
21
+
22
+ return (
23
+ <header className="border-b border-gray-200 bg-white">
24
+ <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
25
+ <Link href="/" className="text-lg font-bold tracking-tight text-gray-900">
26
+ Storefront
27
+ </Link>
28
+ <nav className="flex items-center gap-6 text-sm font-medium text-gray-600">
29
+ <Link href="/" className="hover:text-gray-900">
30
+ Home
31
+ </Link>
32
+ <Link href="/products" className="hover:text-gray-900">
33
+ Products
34
+ </Link>
35
+ <Link href="/cart" className="relative hover:text-gray-900">
36
+ Cart
37
+ {mounted && itemCount > 0 && (
38
+ <span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-xs font-semibold text-white">
39
+ {itemCount}
40
+ </span>
41
+ )}
42
+ </Link>
43
+ </nav>
44
+ </div>
45
+ </header>
46
+ );
47
+ }
@@ -0,0 +1,40 @@
1
+ import type { Product } from '@forgecart/sdk/shop';
2
+ import Link from 'next/link';
3
+
4
+ import { formatPrice, getStartingPrice } from '../lib/forgecart';
5
+
6
+ /** A product tile linking to its detail page. Used by the home and grid pages. */
7
+ export function ProductCard({ product }: { product: Product }) {
8
+ const startingPrice = getStartingPrice(product);
9
+
10
+ return (
11
+ <Link
12
+ href={`/products/${product.slug}`}
13
+ className="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition hover:shadow-md"
14
+ >
15
+ <div className="aspect-square overflow-hidden bg-gray-100">
16
+ {product.featuredAsset ? (
17
+ // Plain <img> keeps the template dependency-free of next/image config.
18
+ // eslint-disable-next-line @next/next/no-img-element
19
+ <img
20
+ src={product.featuredAsset.preview}
21
+ alt={product.name}
22
+ className="h-full w-full object-cover transition group-hover:scale-105"
23
+ />
24
+ ) : (
25
+ <div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
26
+ No image
27
+ </div>
28
+ )}
29
+ </div>
30
+ <div className="flex flex-1 flex-col gap-1 p-4">
31
+ <h3 className="font-medium text-gray-900">{product.name}</h3>
32
+ {startingPrice !== null && (
33
+ <p className="text-sm text-gray-600">
34
+ From {formatPrice(startingPrice)}
35
+ </p>
36
+ )}
37
+ </div>
38
+ </Link>
39
+ );
40
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import type { Product, ProductVariant } from '@forgecart/sdk/shop';
4
+ import { useMemo, useState } from 'react';
5
+
6
+ import { useCart } from '../lib/cart-context';
7
+ import { formatPrice } from '../lib/forgecart';
8
+
9
+ /**
10
+ * Variant picker + add-to-cart for the product detail page.
11
+ *
12
+ * The customer picks one value per option group (Size, Color, …); the matching
13
+ * variant drives the displayed price and what gets added to the cart — you add a
14
+ * **variant**, never the product. Single-variant products skip the selectors.
15
+ */
16
+ export function ProductPurchase({ product }: { product: Product }) {
17
+ const { add, pending } = useCart();
18
+ const [added, setAdded] = useState(false);
19
+
20
+ // Default selection = the first variant's option values, so a price shows
21
+ // immediately. Keyed by option group id -> selected option id.
22
+ const [selected, setSelected] = useState<Record<string, string>>(() => {
23
+ const sel: Record<string, string> = {};
24
+ for (const option of product.variants[0]?.options ?? []) {
25
+ sel[option.groupId] = option.id;
26
+ }
27
+ return sel;
28
+ });
29
+
30
+ const variant: ProductVariant | undefined = useMemo(() => {
31
+ if (product.variants.length <= 1) {
32
+ return product.variants[0];
33
+ }
34
+ return product.variants.find(
35
+ (v) => v.options.length > 0 && v.options.every((o) => selected[o.groupId] === o.id),
36
+ );
37
+ }, [product.variants, selected]);
38
+
39
+ const onAdd = () => {
40
+ if (!variant) {
41
+ return;
42
+ }
43
+ add(variant.id, 1);
44
+ setAdded(true);
45
+ window.setTimeout(() => setAdded(false), 1500);
46
+ };
47
+
48
+ return (
49
+ <div className="space-y-4">
50
+ {variant ? (
51
+ <p className="text-xl font-semibold text-gray-900">{formatPrice(variant.priceWithTax)}</p>
52
+ ) : (
53
+ <p className="text-sm text-gray-500">Select options to see the price.</p>
54
+ )}
55
+
56
+ {product.optionGroups.map((group) => (
57
+ <div key={group.id}>
58
+ <h3 className="mb-1 text-sm font-medium text-gray-700">{group.name}</h3>
59
+ <div className="flex flex-wrap gap-2">
60
+ {group.options.map((opt) => {
61
+ const active = selected[group.id] === opt.id;
62
+ return (
63
+ <button
64
+ key={opt.id}
65
+ type="button"
66
+ onClick={() => setSelected((s) => ({ ...s, [group.id]: opt.id }))}
67
+ className={`rounded-md border px-3 py-1.5 text-sm transition ${
68
+ active
69
+ ? 'border-gray-900 bg-gray-900 text-white'
70
+ : 'border-gray-300 text-gray-700 hover:border-gray-900'
71
+ }`}
72
+ >
73
+ {opt.name}
74
+ </button>
75
+ );
76
+ })}
77
+ </div>
78
+ </div>
79
+ ))}
80
+
81
+ <button
82
+ type="button"
83
+ onClick={onAdd}
84
+ disabled={!variant || pending}
85
+ className="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
86
+ >
87
+ {pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
88
+ </button>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,148 @@
1
+ 'use server';
2
+
3
+ import type { Order } from '@forgecart/sdk/shop';
4
+ import { cookies } from 'next/headers';
5
+
6
+ /**
7
+ * Server-side cart, backed by the ForgeCart shop **order** API (the real cart),
8
+ * not browser storage. Every export here is a Server Action returning the live
9
+ * `Order` (the SDK type) so callers render straight off it.
10
+ *
11
+ * The anonymous active order is tracked by a session token: the shop API returns
12
+ * a fresh token in the `forgecart-auth-token` response header on the first
13
+ * mutation, which we persist into the httpOnly `forgecart-session` cookie and
14
+ * send back as a bearer token on every subsequent request. The channel token and
15
+ * the session never reach the browser — all cart calls run here, on the server.
16
+ */
17
+
18
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
19
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
20
+ const SESSION_COOKIE = 'forgecart-session';
21
+
22
+ /** The order fields the storefront renders (a subset of the SDK `Order` type). */
23
+ const ORDER_FIELDS = `
24
+ id
25
+ totalQuantity
26
+ subTotalWithTax
27
+ totalWithTax
28
+ currencyCode
29
+ lines {
30
+ id
31
+ quantity
32
+ unitPriceWithTax
33
+ linePriceWithTax
34
+ productVariant {
35
+ id
36
+ name
37
+ }
38
+ featuredAsset {
39
+ preview
40
+ }
41
+ }
42
+ `;
43
+
44
+ interface GraphQLResponse<T> {
45
+ data?: T;
46
+ errors?: { message: string }[];
47
+ }
48
+
49
+ /**
50
+ * Call the channel's shop API for a cart operation.
51
+ *
52
+ * `persist` MUST be `false` when called from a Server Component (the initial
53
+ * cart read): Server Components cannot set cookies — only Server Actions (the
54
+ * mutations) can, so they capture/refresh the session token.
55
+ */
56
+ async function shopFetch<T>(
57
+ query: string,
58
+ variables: Record<string, unknown>,
59
+ persist: boolean,
60
+ ): Promise<GraphQLResponse<T>> {
61
+ const store = await cookies();
62
+ const session = store.get(SESSION_COOKIE)?.value;
63
+
64
+ const headers: Record<string, string> = {
65
+ 'content-type': 'application/json',
66
+ 'forgecart-token': CHANNEL_TOKEN,
67
+ };
68
+ if (session) {
69
+ headers.authorization = `Bearer ${session}`;
70
+ }
71
+
72
+ const res = await fetch(SHOP_API_URL, {
73
+ method: 'POST',
74
+ headers,
75
+ body: JSON.stringify({ query, variables }),
76
+ cache: 'no-store',
77
+ });
78
+
79
+ if (persist) {
80
+ const token = res.headers.get('forgecart-auth-token');
81
+ if (token && token !== session) {
82
+ store.set(SESSION_COOKIE, token, {
83
+ httpOnly: true,
84
+ sameSite: 'lax',
85
+ path: '/',
86
+ maxAge: 60 * 60 * 24 * 30,
87
+ });
88
+ }
89
+ }
90
+
91
+ return (await res.json()) as GraphQLResponse<T>;
92
+ }
93
+
94
+ function unwrap<T>(res: GraphQLResponse<T>): T {
95
+ if (res.errors?.length) {
96
+ throw new Error(res.errors[0].message);
97
+ }
98
+ if (!res.data) {
99
+ throw new Error('Empty response from shop API');
100
+ }
101
+ return res.data;
102
+ }
103
+
104
+ /** Read the active order (cart). Safe to call from a Server Component. */
105
+ export async function getCart(): Promise<Order | null> {
106
+ const res = await shopFetch<{ activeOrder: Order | null }>(
107
+ `query { activeOrder { ${ORDER_FIELDS} } }`,
108
+ {},
109
+ false,
110
+ );
111
+ return res.data?.activeOrder ?? null;
112
+ }
113
+
114
+ /** Add a variant to the cart (creates the order + session on first call). */
115
+ export async function addToCart(variantId: string, quantity = 1): Promise<Order | null> {
116
+ const data = unwrap(
117
+ await shopFetch<{ addItemToOrder: Order }>(
118
+ `mutation($input: ShopAddItemToOrderInput!) { addItemToOrder(input: $input) { ${ORDER_FIELDS} } }`,
119
+ { input: { variantId, quantity } },
120
+ true,
121
+ ),
122
+ );
123
+ return data.addItemToOrder;
124
+ }
125
+
126
+ /** Set the quantity of a cart line (0 removes it). */
127
+ export async function updateLine(lineId: string, quantity: number): Promise<Order | null> {
128
+ const data = unwrap(
129
+ await shopFetch<{ adjustOrderLine: Order }>(
130
+ `mutation($input: ShopAdjustOrderLineInput!) { adjustOrderLine(input: $input) { ${ORDER_FIELDS} } }`,
131
+ { input: { lineId, quantity } },
132
+ true,
133
+ ),
134
+ );
135
+ return data.adjustOrderLine;
136
+ }
137
+
138
+ /** Remove a line from the cart. */
139
+ export async function removeLine(lineId: string): Promise<Order | null> {
140
+ const data = unwrap(
141
+ await shopFetch<{ removeOrderLine: Order }>(
142
+ `mutation($lineId: ID!) { removeOrderLine(lineId: $lineId) { ${ORDER_FIELDS} } }`,
143
+ { lineId },
144
+ true,
145
+ ),
146
+ );
147
+ return data.removeOrderLine;
148
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import type { Order } from '@forgecart/sdk/shop';
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useMemo,
9
+ useState,
10
+ useTransition,
11
+ type ReactNode,
12
+ } from 'react';
13
+
14
+ import { addToCart, getCart, removeLine as removeLineAction, updateLine } from './cart-actions';
15
+
16
+ /**
17
+ * Cart state backed by the ForgeCart shop order API (see `cart-actions.ts`).
18
+ *
19
+ * `cart` is the live `Order` (the SDK type). The provider is seeded with the
20
+ * server-fetched active order (`initialCart`) so the first paint is correct,
21
+ * then every mutation calls a Server Action and replaces it with the order the
22
+ * API returns. `pending` is true while a cart mutation is in flight.
23
+ */
24
+ interface CartContextValue {
25
+ cart: Order | null;
26
+ itemCount: number;
27
+ subtotal: number;
28
+ pending: boolean;
29
+ add: (variantId: string, quantity?: number) => void;
30
+ setQuantity: (lineId: string, quantity: number) => void;
31
+ remove: (lineId: string) => void;
32
+ refresh: () => void;
33
+ }
34
+
35
+ const CartContext = createContext<CartContextValue | null>(null);
36
+
37
+ export function CartProvider({
38
+ initialCart,
39
+ children,
40
+ }: {
41
+ initialCart: Order | null;
42
+ children: ReactNode;
43
+ }) {
44
+ const [cart, setCart] = useState<Order | null>(initialCart);
45
+ const [pending, startTransition] = useTransition();
46
+
47
+ const run = useCallback((op: () => Promise<Order | null>) => {
48
+ startTransition(async () => {
49
+ setCart(await op());
50
+ });
51
+ }, []);
52
+
53
+ const add = useCallback(
54
+ (variantId: string, quantity = 1) => run(() => addToCart(variantId, quantity)),
55
+ [run],
56
+ );
57
+ const setQuantity = useCallback(
58
+ (lineId: string, quantity: number) => run(() => updateLine(lineId, quantity)),
59
+ [run],
60
+ );
61
+ const remove = useCallback((lineId: string) => run(() => removeLineAction(lineId)), [run]);
62
+ const refresh = useCallback(() => run(() => getCart()), [run]);
63
+
64
+ const value = useMemo<CartContextValue>(
65
+ () => ({
66
+ cart,
67
+ itemCount: cart?.totalQuantity ?? 0,
68
+ subtotal: cart?.subTotalWithTax ?? 0,
69
+ pending,
70
+ add,
71
+ setQuantity,
72
+ remove,
73
+ refresh,
74
+ }),
75
+ [cart, pending, add, setQuantity, remove, refresh],
76
+ );
77
+
78
+ return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
79
+ }
80
+
81
+ /** Access the cart. Must be used inside a `<CartProvider>`. */
82
+ export function useCart(): CartContextValue {
83
+ const ctx = useContext(CartContext);
84
+ if (!ctx) {
85
+ throw new Error('useCart must be used within a CartProvider');
86
+ }
87
+ return ctx;
88
+ }
@@ -0,0 +1,187 @@
1
+ import { ForgeCartShopClient } from '@forgecart/sdk';
2
+ import type { Product, ProductList } from '@forgecart/sdk/shop';
3
+
4
+ /**
5
+ * Server-side ForgeCart shop client.
6
+ *
7
+ * The storefront talks to the ForgeCart shop GraphQL API on behalf of a single
8
+ * channel. The channel token is sent as the `forgecart-token` header on every
9
+ * request (the SDK adds it from `channelToken`), and the endpoint points at the
10
+ * channel's shop-api.
11
+ *
12
+ * Both values come from the environment (`.env.local`, written by
13
+ * `forgecart init`):
14
+ * - FORGECART_SHOP_API_URL -> endpoint
15
+ * - FORGECART_CHANNEL_TOKEN -> channelToken
16
+ *
17
+ * This module is server-only: it is imported exclusively from Server
18
+ * Components, so the channel token never reaches the browser. The construction
19
+ * mirrors the dashboard's `SdkClientService` (see
20
+ * app/dashboard/src/service/sdk-client.service.ts), adapted to read from env.
21
+ */
22
+
23
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
24
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
25
+
26
+ let client: ForgeCartShopClient | null = null;
27
+
28
+ /**
29
+ * Lazily construct and memoize the shop client.
30
+ *
31
+ * Constructing per-request would open a fresh websocket transport each time, so
32
+ * we keep a singleton for the lifetime of the server process.
33
+ */
34
+ export function getShopClient(): ForgeCartShopClient {
35
+ if (!SHOP_API_URL) {
36
+ throw new Error(
37
+ 'FORGECART_SHOP_API_URL is not set. Add it to .env.local (forgecart init writes it for you).',
38
+ );
39
+ }
40
+ if (!CHANNEL_TOKEN) {
41
+ throw new Error(
42
+ 'FORGECART_CHANNEL_TOKEN is not set. Add it to .env.local (forgecart init writes it for you).',
43
+ );
44
+ }
45
+ if (!client) {
46
+ client = new ForgeCartShopClient({
47
+ endpoint: SHOP_API_URL,
48
+ channelToken: CHANNEL_TOKEN,
49
+ });
50
+ }
51
+ return client;
52
+ }
53
+
54
+ /** Convenience singleton for direct use in Server Components. */
55
+ export const shopClient = (): ForgeCartShopClient => getShopClient();
56
+
57
+ /* ------------------------------------------------------------------ *
58
+ * Typed product helpers
59
+ *
60
+ * The generated typed operations (`shopClient.product.shopProducts` /
61
+ * `.shopProduct`) only select `id` + `optionGroups`, which is not enough to
62
+ * render a storefront. We therefore use the SDK's supported raw-query escape
63
+ * hatch (`ForgeCartShopClient.query<T>(document, variables)`) with documents
64
+ * that select the display fields, typed against the SDK's own `Product` /
65
+ * `ProductList` types (`@forgecart/sdk/shop`).
66
+ * ------------------------------------------------------------------ */
67
+
68
+ const PRODUCT_FIELDS = `
69
+ id
70
+ name
71
+ slug
72
+ description
73
+ featuredAsset {
74
+ id
75
+ preview
76
+ }
77
+ optionGroups {
78
+ id
79
+ name
80
+ code
81
+ options {
82
+ id
83
+ name
84
+ code
85
+ }
86
+ }
87
+ variants {
88
+ id
89
+ name
90
+ sku
91
+ price
92
+ priceWithTax
93
+ options {
94
+ id
95
+ name
96
+ code
97
+ groupId
98
+ }
99
+ }
100
+ `;
101
+
102
+ const PRODUCTS_QUERY = `query StorefrontProducts($options: ListQueryOptions) {
103
+ products(options: $options) {
104
+ items {${PRODUCT_FIELDS}}
105
+ totalItems
106
+ }
107
+ }`;
108
+
109
+ const PRODUCT_BY_SLUG_QUERY = `query StorefrontProduct($slug: String) {
110
+ product(slug: $slug) {${PRODUCT_FIELDS}}
111
+ }`;
112
+
113
+ const PRODUCT_BY_ID_QUERY = `query StorefrontProductById($id: ID) {
114
+ product(id: $id) {${PRODUCT_FIELDS}}
115
+ }`;
116
+
117
+ interface ProductsQueryResult {
118
+ products: Pick<ProductList, 'items' | 'totalItems'>;
119
+ }
120
+
121
+ interface ProductQueryResult {
122
+ product: Product | null;
123
+ }
124
+
125
+ /**
126
+ * Fetch a page of products for the channel.
127
+ *
128
+ * `take` / `skip` map onto the shop API's `ListQueryOptions`.
129
+ */
130
+ export async function getProducts(
131
+ options: { take?: number; skip?: number } = {},
132
+ ): Promise<{ items: Product[]; totalItems: number }> {
133
+ const { take = 24, skip = 0 } = options;
134
+ const result = await getShopClient().query<ProductsQueryResult>(PRODUCTS_QUERY, {
135
+ options: { take, skip },
136
+ });
137
+ return {
138
+ items: result.products.items,
139
+ totalItems: result.products.totalItems,
140
+ };
141
+ }
142
+
143
+ /** Fetch a single product by its URL slug. Returns `null` if not found. */
144
+ export async function getProductBySlug(slug: string): Promise<Product | null> {
145
+ const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_SLUG_QUERY, {
146
+ slug,
147
+ });
148
+ return result.product ?? null;
149
+ }
150
+
151
+ /** Fetch a single product by id. Returns `null` if not found. */
152
+ export async function getProductById(id: string): Promise<Product | null> {
153
+ const result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_ID_QUERY, {
154
+ id,
155
+ });
156
+ return result.product ?? null;
157
+ }
158
+
159
+ /**
160
+ * Convenience: fetch a small set of products to feature on the home page.
161
+ */
162
+ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
163
+ const { items } = await getProducts({ take: count });
164
+ return items;
165
+ }
166
+
167
+ /** Lowest variant price for a product, in minor units, or `null` if none. */
168
+ export function getStartingPrice(product: Product): number | null {
169
+ if (product.variants.length === 0) {
170
+ return null;
171
+ }
172
+ return product.variants.reduce(
173
+ (min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
174
+ product.variants[0].priceWithTax,
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Format a minor-unit price (cents) as a currency string. Defaults to USD;
180
+ * pass a different ISO currency code as needed.
181
+ */
182
+ export function formatPrice(minorUnits: number, currency = 'USD'): string {
183
+ return new Intl.NumberFormat('en-US', {
184
+ style: 'currency',
185
+ currency,
186
+ }).format(minorUnits / 100);
187
+ }
@@ -5,4 +5,4 @@ module.exports = {
5
5
  extend: {},
6
6
  },
7
7
  plugins: [],
8
- }
8
+ };
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "ES2022"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2022 Vendure
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.