@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.
@@ -1,16 +1,22 @@
1
1
  import { ForgeCartShopClient } from '@forgecart/sdk';
2
- import type { Product, ProductList } from '@forgecart/sdk/shop';
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. 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.
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
- * Both values come from the environment (`.env.local`, written by
13
- * `forgecart init`):
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
- * Constructing per-request would open a fresh websocket transport each time, so
32
- * we keep a singleton for the lifetime of the server process.
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
- * 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
- }
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 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
- };
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 result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_SLUG_QUERY, {
146
- slug,
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 result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_ID_QUERY, {
154
- id,
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) {