@forgecart/cli 2.202606151453.0 → 2.202607070306.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/dist/src/commands/init.d.ts +8 -0
- package/dist/src/commands/init.js +48 -6
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +1 -1
- package/templates/storefront/src/app/__forge_beacon/route.ts +36 -0
- package/templates/storefront/src/app/cart/page.tsx +9 -84
- package/templates/storefront/src/app/layout.tsx +2 -0
- package/templates/storefront/src/app/ping/route.ts +22 -0
- package/templates/storefront/src/app/products/[slug]/page.tsx +22 -3
- package/templates/storefront/src/components/CartView.tsx +161 -0
- package/templates/storefront/src/components/ForgeErrorBeacon.tsx +71 -0
- package/templates/storefront/src/components/ProductCard.tsx +1 -2
- package/templates/storefront/src/components/ProductPurchase.tsx +153 -8
- package/templates/storefront/src/instrumentation.ts +69 -0
- package/templates/storefront/src/lib/cart-actions.ts +95 -112
- package/templates/storefront/src/lib/cart-context.tsx +18 -5
- package/templates/storefront/src/lib/forgecart.ts +94 -91
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ShopProductFieldFragment as Product,
|
|
4
|
+
ShopSellingPlanFieldFragment as SellingPlan,
|
|
5
|
+
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
6
|
+
} from '@forgecart/sdk/shop';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Server-side ForgeCart shop client.
|
|
6
10
|
*
|
|
7
11
|
* The storefront talks to the ForgeCart shop GraphQL API on behalf of a single
|
|
8
|
-
* channel
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* channel through the SDK's generated, typed operations — every read here is a
|
|
13
|
+
* typed method on the client (no hand-written query documents), and every type
|
|
14
|
+
* the components render is the SDK's own (`@forgecart/sdk/shop`).
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
16
|
+
* The channel token is sent as the `forgecart-token` header on every request
|
|
17
|
+
* (the SDK adds it from `channelToken`), and the endpoint points at the
|
|
18
|
+
* channel's shop-api. Both values come from the environment (`.env.local`,
|
|
19
|
+
* written by `forgecart init`):
|
|
14
20
|
* - FORGECART_SHOP_API_URL -> endpoint
|
|
15
21
|
* - FORGECART_CHANNEL_TOKEN -> channelToken
|
|
16
22
|
*
|
|
@@ -26,10 +32,12 @@ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
|
26
32
|
let client: ForgeCartShopClient | null = null;
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
* Lazily construct and memoize the shop client.
|
|
35
|
+
* Lazily construct and memoize the channel-scoped shop client.
|
|
30
36
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
37
|
+
* All reads here are anonymous (channel scope only), so one client instance
|
|
38
|
+
* serves the whole server process. Per-shopper session state lives in
|
|
39
|
+
* `cart-actions.ts`, which constructs a per-request client around the session
|
|
40
|
+
* cookie instead.
|
|
33
41
|
*/
|
|
34
42
|
export function getShopClient(): ForgeCartShopClient {
|
|
35
43
|
if (!SHOP_API_URL) {
|
|
@@ -54,73 +62,20 @@ export function getShopClient(): ForgeCartShopClient {
|
|
|
54
62
|
/** Convenience singleton for direct use in Server Components. */
|
|
55
63
|
export const shopClient = (): ForgeCartShopClient => getShopClient();
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
}
|
|
65
|
+
// The SDK's operation-shaped fragment types, re-exported under their domain
|
|
66
|
+
// names so components import them from one place. These are exactly what the
|
|
67
|
+
// typed operations return: `Product`/`ProductVariant` carry the storefront
|
|
68
|
+
// display fields (name, slug, assets, priced variants); `Order`/`OrderLine`
|
|
69
|
+
// the live cart incl. the subscription fields; the selling-plan pair backs the
|
|
70
|
+
// subscription selector and the whole-cart subscribe box.
|
|
71
|
+
export type {
|
|
72
|
+
ShopOrderFieldFragment as Order,
|
|
73
|
+
ShopOrderLineFieldFragment as OrderLine,
|
|
74
|
+
ShopProductFieldFragment as Product,
|
|
75
|
+
ShopProductVariantFieldFragment as ProductVariant,
|
|
76
|
+
ShopSellingPlanFieldFragment as SellingPlan,
|
|
77
|
+
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
78
|
+
} from '@forgecart/sdk/shop';
|
|
124
79
|
|
|
125
80
|
/**
|
|
126
81
|
* Fetch a page of products for the channel.
|
|
@@ -131,29 +86,20 @@ export async function getProducts(
|
|
|
131
86
|
options: { take?: number; skip?: number } = {},
|
|
132
87
|
): Promise<{ items: Product[]; totalItems: number }> {
|
|
133
88
|
const { take = 24, skip = 0 } = options;
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
items: result.products.items,
|
|
139
|
-
totalItems: result.products.totalItems,
|
|
140
|
-
};
|
|
89
|
+
const { products } = await getShopClient().product.shopProducts({ options: { take, skip } });
|
|
90
|
+
return { items: products.items, totalItems: products.totalItems };
|
|
141
91
|
}
|
|
142
92
|
|
|
143
93
|
/** Fetch a single product by its URL slug. Returns `null` if not found. */
|
|
144
94
|
export async function getProductBySlug(slug: string): Promise<Product | null> {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
});
|
|
148
|
-
return result.product ?? null;
|
|
95
|
+
const { product } = await getShopClient().product.shopProduct({ slug });
|
|
96
|
+
return product ?? null;
|
|
149
97
|
}
|
|
150
98
|
|
|
151
99
|
/** Fetch a single product by id. Returns `null` if not found. */
|
|
152
100
|
export async function getProductById(id: string): Promise<Product | null> {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
});
|
|
156
|
-
return result.product ?? null;
|
|
101
|
+
const { product } = await getShopClient().product.shopProduct({ id });
|
|
102
|
+
return product ?? null;
|
|
157
103
|
}
|
|
158
104
|
|
|
159
105
|
/**
|
|
@@ -164,6 +110,63 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
|
|
|
164
110
|
return items;
|
|
165
111
|
}
|
|
166
112
|
|
|
113
|
+
/** Fetch the subscription groups a given variant is eligible for (per-line subscribe). */
|
|
114
|
+
export async function getSellingPlanGroupsForVariant(
|
|
115
|
+
variantId: string,
|
|
116
|
+
): Promise<SellingPlanGroup[]> {
|
|
117
|
+
const { sellingPlanGroupsForVariant } = await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
|
|
118
|
+
return sellingPlanGroupsForVariant;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fetch the channel-wide subscription groups (whole-cart subscribe box). */
|
|
122
|
+
export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]> {
|
|
123
|
+
const { channelSellingPlanGroups } = await getShopClient().sellingPlan.channelSellingPlanGroups();
|
|
124
|
+
return channelSellingPlanGroups;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Preview the per-unit price a plan yields from the one-time `priceWithTax`.
|
|
129
|
+
*
|
|
130
|
+
* Mirrors the server's pricing policy: `none` keeps the price, `percentage`
|
|
131
|
+
* applies the percent discount (rounded to whole minor units), `fixed_amount`
|
|
132
|
+
* subtracts the minor-unit adjustment (floored at zero). A `null`
|
|
133
|
+
* `adjustmentValue` (only valid for `none`) is treated as no adjustment.
|
|
134
|
+
*/
|
|
135
|
+
export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
|
|
136
|
+
if (plan.pricingPolicy === 'percentage') {
|
|
137
|
+
const adjustment = plan.adjustmentValue ?? 0;
|
|
138
|
+
// `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
|
|
139
|
+
const PERCENT_BASE = 100;
|
|
140
|
+
return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
|
|
141
|
+
}
|
|
142
|
+
if (plan.pricingPolicy === 'fixed_amount') {
|
|
143
|
+
return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
|
|
144
|
+
}
|
|
145
|
+
return basePrice;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A short human-readable savings hint for a plan, or `null` when it offers no
|
|
150
|
+
* discount (policy `none`, a zero adjustment, or a non-positive percentage).
|
|
151
|
+
*/
|
|
152
|
+
export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
|
|
153
|
+
if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
|
|
154
|
+
return `Save ${plan.adjustmentValue}%`;
|
|
155
|
+
}
|
|
156
|
+
if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
|
|
157
|
+
return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
|
|
164
|
+
* built from the plan's interval count and the (lower-cased) billing interval.
|
|
165
|
+
*/
|
|
166
|
+
export function getPlanCadenceLabel(plan: SellingPlan): string {
|
|
167
|
+
return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
167
170
|
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
168
171
|
export function getStartingPrice(product: Product): number | null {
|
|
169
172
|
if (product.variants.length === 0) {
|