@occluze/tether 0.6.7 → 0.7.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/README.md +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +55 -39
- package/dist/tether.account-pages.js +781 -495
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +462 -381
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +299 -194
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +354 -262
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +82 -45
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3653 -2116
- package/dist/tether.markets.js +238 -150
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +207 -136
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +42 -22
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/order-lines.ts +75 -0
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/page-events.ts +253 -0
- package/src/account/server-client.ts +183 -0
- package/src/account/subscriptions-view.ts +115 -0
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/account-pages.ts +81 -0
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/analytics-shopify.ts +246 -0
- package/src/analytics.ts +315 -0
- package/src/api-version.ts +79 -0
- package/src/astro.ts +272 -0
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/drawer.ts +125 -0
- package/src/cart/mutations.ts +237 -0
- package/src/cart/render.ts +266 -0
- package/src/cart/store.ts +279 -0
- package/src/cart.ts +345 -0
- package/src/client.ts +391 -0
- package/src/collection.ts +175 -0
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/cart-controls.ts +123 -0
- package/src/controls/discount.ts +75 -0
- package/src/controls/extras.ts +54 -0
- package/src/controls/navigation.ts +77 -0
- package/src/diagnose-markup.ts +298 -0
- package/src/diagnose.ts +294 -0
- package/src/dom.ts +268 -0
- package/src/extras.ts +136 -0
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/images.ts +91 -0
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/loader.ts +363 -0
- package/src/market-codes.ts +6 -0
- package/src/markets-ui.ts +227 -0
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/mounting.ts +264 -0
- package/src/page-market.ts +110 -0
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/product-events.ts +115 -0
- package/src/product-render.ts +218 -0
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/product.ts +308 -0
- package/src/repeaters.ts +161 -0
- package/src/search.ts +305 -0
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/shopify-values.ts +91 -0
- package/src/status.ts +106 -0
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/json-ld.ts +144 -0
- package/src/variant-render.ts +139 -0
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
- package/src/account-pages.js +0 -318
- package/src/account.js +0 -385
- package/src/analytics.js +0 -232
- package/src/api-version.js +0 -35
- package/src/astro.js +0 -101
- package/src/cart.js +0 -559
- package/src/client.js +0 -131
- package/src/collection.js +0 -114
- package/src/config.js +0 -85
- package/src/diagnose.js +0 -246
- package/src/dom.js +0 -288
- package/src/extras.js +0 -63
- package/src/fragments.js +0 -144
- package/src/index.js +0 -395
- package/src/initial.js +0 -75
- package/src/lazy-iife.js +0 -46
- package/src/lazy.js +0 -18
- package/src/loader.js +0 -198
- package/src/markets-ui.js +0 -90
- package/src/markets.js +0 -133
- package/src/mounting.js +0 -154
- package/src/parts/account-pages.js +0 -3
- package/src/parts/account.js +0 -3
- package/src/parts/analytics.js +0 -3
- package/src/parts/diagnose.js +0 -3
- package/src/parts/extras.js +0 -3
- package/src/parts/markets.js +0 -3
- package/src/parts/search.js +0 -3
- package/src/presets/barba.js +0 -100
- package/src/product.js +0 -570
- package/src/search.js +0 -153
- package/src/selling-plans.js +0 -94
- package/src/server.js +0 -252
- package/src/status.js +0 -66
- package/types/astro.d.ts +0 -17
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -426
- package/types/server.d.ts +0 -110
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product scope shows for the visitor's choice: the variant, the purchase it is bought as, its prices, the
|
|
3
|
+
* text of every `tether-bind` and every `tether-show` condition. Pure, no DOM and no config: the browser scope
|
|
4
|
+
* (`product.ts`) and the server's `initialState()` (`initial.ts`) derive their view here, so a server-rendered page
|
|
5
|
+
* shows what the script shows when it takes over.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
GID,
|
|
9
|
+
allPlans,
|
|
10
|
+
allocationFor,
|
|
11
|
+
findPlan,
|
|
12
|
+
planAdjustment,
|
|
13
|
+
planCheckoutCharge,
|
|
14
|
+
planPrice,
|
|
15
|
+
planSavings,
|
|
16
|
+
} from './selling-plans.ts';
|
|
17
|
+
import { fromMinorUnits, toMinorUnits } from './amounts.ts';
|
|
18
|
+
import type { Money } from './shopify-values.ts';
|
|
19
|
+
import type { Product, ProductVariant } from './fragments.ts';
|
|
20
|
+
import type { PlanInGroup } from './selling-plans.ts';
|
|
21
|
+
|
|
22
|
+
/** Option name → chosen value. */
|
|
23
|
+
export type Selection = Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* How the visitor wants to buy. Buying once keeps the plan offered next to the one-time price (the subscription
|
|
26
|
+
* binds `subscription-price` and `plan-name` show it, and subscribing switches to it); `null` for a product without
|
|
27
|
+
* plans.
|
|
28
|
+
*/
|
|
29
|
+
export type Purchase = { kind: 'one-time'; offeredPlanId: string | null } | { kind: 'subscription'; planId: string };
|
|
30
|
+
/** How the line is bought: on a plan only when the visitor subscribes and the variant is on that plan. */
|
|
31
|
+
type LinePurchase = { kind: 'one-time' } | { kind: 'subscription'; planId: string };
|
|
32
|
+
/** What the visitor chose, before it is checked against the product. */
|
|
33
|
+
interface ProductChoice {
|
|
34
|
+
selected: Selection;
|
|
35
|
+
purchase: Purchase;
|
|
36
|
+
quantity: number;
|
|
37
|
+
}
|
|
38
|
+
export type ShowCondition =
|
|
39
|
+
| 'in-stock'
|
|
40
|
+
| 'out-of-stock'
|
|
41
|
+
| 'backorder'
|
|
42
|
+
| 'on-sale'
|
|
43
|
+
| 'not-on-sale'
|
|
44
|
+
| 'has-variants'
|
|
45
|
+
| 'single-variant'
|
|
46
|
+
| 'low-stock'
|
|
47
|
+
| 'plenty-in-stock'
|
|
48
|
+
| 'subscription-available'
|
|
49
|
+
| 'subscription-unavailable'
|
|
50
|
+
| 'subscription-only'
|
|
51
|
+
| 'one-time-available'
|
|
52
|
+
| 'subscription-selected'
|
|
53
|
+
| 'one-time-selected'
|
|
54
|
+
| 'has-savings';
|
|
55
|
+
export interface ProductView {
|
|
56
|
+
variant: ProductVariant;
|
|
57
|
+
/** The selection, matching `variant`. */
|
|
58
|
+
selected: Selection;
|
|
59
|
+
/** The choice, made valid for the product and `variant`. */
|
|
60
|
+
purchase: Purchase;
|
|
61
|
+
/** The purchase's plan, offered or chosen. */
|
|
62
|
+
plan: PlanInGroup | null;
|
|
63
|
+
/** How the line is bought. */
|
|
64
|
+
line: LinePurchase;
|
|
65
|
+
/** The variant is on at least one plan. */
|
|
66
|
+
subscriptionAvailable: boolean;
|
|
67
|
+
quantity: number;
|
|
68
|
+
/** What one item costs as bought. */
|
|
69
|
+
price: Money;
|
|
70
|
+
/** Only when on sale. */
|
|
71
|
+
compareAtPrice: Money | null;
|
|
72
|
+
/** What `plan` saves on the one-time price. */
|
|
73
|
+
savings: { amount: Money | null; percentage: number };
|
|
74
|
+
show: Record<ShowCondition, boolean>;
|
|
75
|
+
}
|
|
76
|
+
/** How the caller writes values as text. */
|
|
77
|
+
export interface Formatters {
|
|
78
|
+
money: (money: Money | null) => string;
|
|
79
|
+
percent: (percentage: number) => string;
|
|
80
|
+
}
|
|
81
|
+
/** What a bind writes; `null` and `undefined` clear it. */
|
|
82
|
+
type BindText = string | number | null | undefined;
|
|
83
|
+
|
|
84
|
+
const PERCENT = 100;
|
|
85
|
+
|
|
86
|
+
const timesQuantity = (money: Money, quantity: number): Money =>
|
|
87
|
+
fromMinorUnits(toMinorUnits(money) * quantity, money.currencyCode);
|
|
88
|
+
|
|
89
|
+
export const selectionOf = (variant: ProductVariant): Selection =>
|
|
90
|
+
Object.fromEntries(variant.selectedOptions.map((option) => [option.name, option.value]));
|
|
91
|
+
|
|
92
|
+
/** @returns The variant with exactly these option values. */
|
|
93
|
+
function findVariant(product: Product, selected: Selection): ProductVariant | null {
|
|
94
|
+
return (
|
|
95
|
+
product.variants.nodes.find((variant) =>
|
|
96
|
+
variant.selectedOptions.every((option) => selected[option.name] === option.value),
|
|
97
|
+
) ?? null
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param name An option name.
|
|
103
|
+
* @returns True when a variant for sale has this value and the other selected values.
|
|
104
|
+
*/
|
|
105
|
+
export function isValueAvailable(product: Product, selected: Selection, name: string, value: string): boolean {
|
|
106
|
+
return product.variants.nodes.some(
|
|
107
|
+
(variant) =>
|
|
108
|
+
variant.availableForSale &&
|
|
109
|
+
variant.selectedOptions.every((option) =>
|
|
110
|
+
option.name === name ? option.value === value : selected[option.name] === option.value,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @returns The plan subscribed on, or offered while buying once. */
|
|
116
|
+
export const purchasePlanId = (purchase: Purchase): string | null =>
|
|
117
|
+
purchase.kind === 'subscription' ? purchase.planId : purchase.offeredPlanId;
|
|
118
|
+
|
|
119
|
+
/** @returns On the plan when subscribing and the variant is on it, else once. */
|
|
120
|
+
export function linePurchase(purchase: Purchase, variant: ProductVariant | null): LinePurchase {
|
|
121
|
+
if (purchase.kind === 'subscription' && allocationFor(variant, purchase.planId)) return purchase;
|
|
122
|
+
return { kind: 'one-time' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The purchase the visitor wants, made valid for the product and variant: one-time without plans, a subscription when
|
|
127
|
+
* the product requires one, and a plan the variant is on when it is on any (else a plan of the product, for the binds
|
|
128
|
+
* to offer).
|
|
129
|
+
*
|
|
130
|
+
* @param kind The way of buying the visitor asked for.
|
|
131
|
+
* @param preferredPlanId The plan to keep when it is the product's: the visitor's last one.
|
|
132
|
+
*/
|
|
133
|
+
export function normalisePurchase(
|
|
134
|
+
product: Product,
|
|
135
|
+
variant: ProductVariant | null,
|
|
136
|
+
kind: Purchase['kind'],
|
|
137
|
+
preferredPlanId: string | null,
|
|
138
|
+
): Purchase {
|
|
139
|
+
const plans = allPlans(product);
|
|
140
|
+
const known = plans.find((plan) => plan.id === preferredPlanId) ?? plans[0];
|
|
141
|
+
if (!known) return { kind: 'one-time', offeredPlanId: null };
|
|
142
|
+
const onVariant = allocationFor(variant, known.id)
|
|
143
|
+
? known
|
|
144
|
+
: (plans.find((plan) => allocationFor(variant, plan.id)) ?? known);
|
|
145
|
+
if (product.requiresSellingPlan || kind === 'subscription') return { kind: 'subscription', planId: onVariant.id };
|
|
146
|
+
return { kind: 'one-time', offeredPlanId: onVariant.id };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface InitialChoiceOptions {
|
|
150
|
+
/** A variant id or gid (`?variant=`); the first variant for sale without. */
|
|
151
|
+
variant?: string | null | undefined;
|
|
152
|
+
/** A plan to subscribe on (`?selling_plan=`). */
|
|
153
|
+
sellingPlan?: string | null | undefined;
|
|
154
|
+
/** The plan offered first, by id, name or option value. */
|
|
155
|
+
defaultPlan?: string | null | undefined;
|
|
156
|
+
/** `subscription` subscribes; anything else buys once. */
|
|
157
|
+
defaultPurchase?: string | null | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What a product starts with before the visitor chooses anything. */
|
|
161
|
+
export function initialChoice(
|
|
162
|
+
product: Product,
|
|
163
|
+
{ variant, sellingPlan, defaultPlan, defaultPurchase }: InitialChoiceOptions,
|
|
164
|
+
): Omit<ProductChoice, 'quantity'> {
|
|
165
|
+
const variants = product.variants.nodes;
|
|
166
|
+
const wantedId = GID.variant(variant);
|
|
167
|
+
const start =
|
|
168
|
+
variants.find((candidate) => candidate.id === wantedId) ??
|
|
169
|
+
variants.find((candidate) => candidate.availableForSale) ??
|
|
170
|
+
variants[0] ??
|
|
171
|
+
null;
|
|
172
|
+
const fromUrl = findPlan(product, sellingPlan);
|
|
173
|
+
const offered = fromUrl ?? findPlan(product, defaultPlan);
|
|
174
|
+
const kind = fromUrl || defaultPurchase === 'subscription' ? 'subscription' : 'one-time';
|
|
175
|
+
return {
|
|
176
|
+
selected: start ? selectionOf(start) : {},
|
|
177
|
+
purchase: normalisePurchase(product, start, kind, offered?.id ?? null),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showConditions(
|
|
182
|
+
variant: ProductVariant,
|
|
183
|
+
lowStock: number,
|
|
184
|
+
variantCount: number,
|
|
185
|
+
sale: Pick<ProductView, 'line' | 'subscriptionAvailable' | 'compareAtPrice' | 'savings'>,
|
|
186
|
+
requiresSellingPlan: boolean,
|
|
187
|
+
): Record<ShowCondition, boolean> {
|
|
188
|
+
const stock = variant.quantityAvailable;
|
|
189
|
+
const low = stock != null && stock > 0 && stock <= lowStock;
|
|
190
|
+
const onSale = sale.compareAtPrice !== null;
|
|
191
|
+
const onPlan = sale.line.kind === 'subscription';
|
|
192
|
+
return {
|
|
193
|
+
'in-stock': variant.availableForSale,
|
|
194
|
+
'out-of-stock': !variant.availableForSale,
|
|
195
|
+
backorder: variant.availableForSale && variant.currentlyNotInStock,
|
|
196
|
+
'on-sale': onSale,
|
|
197
|
+
'not-on-sale': !onSale,
|
|
198
|
+
'has-variants': variantCount > 1,
|
|
199
|
+
'single-variant': variantCount === 1,
|
|
200
|
+
'low-stock': low,
|
|
201
|
+
'plenty-in-stock': variant.availableForSale && !low,
|
|
202
|
+
'subscription-available': sale.subscriptionAvailable,
|
|
203
|
+
'subscription-unavailable': !sale.subscriptionAvailable,
|
|
204
|
+
'subscription-only': requiresSellingPlan,
|
|
205
|
+
'one-time-available': !requiresSellingPlan,
|
|
206
|
+
'subscription-selected': onPlan,
|
|
207
|
+
'one-time-selected': !onPlan,
|
|
208
|
+
'has-savings': onPlan ? sale.savings.percentage > 0 : onSale,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The view for the visitor's choice. A selection no variant has falls back to the first variant.
|
|
214
|
+
*
|
|
215
|
+
* @param lowStock `low-stock` at this many or fewer.
|
|
216
|
+
* @returns `null` for a product without variants.
|
|
217
|
+
*/
|
|
218
|
+
export function productView(product: Product, choice: ProductChoice, lowStock: number): ProductView | null {
|
|
219
|
+
const exact = findVariant(product, choice.selected);
|
|
220
|
+
const variant = exact ?? product.variants.nodes[0];
|
|
221
|
+
if (!variant) return null;
|
|
222
|
+
const purchase = normalisePurchase(product, variant, choice.purchase.kind, purchasePlanId(choice.purchase));
|
|
223
|
+
const planId = purchasePlanId(purchase);
|
|
224
|
+
const plans = allPlans(product);
|
|
225
|
+
const line = linePurchase(purchase, variant);
|
|
226
|
+
const allocation = line.kind === 'subscription' ? allocationFor(variant, line.planId) : null;
|
|
227
|
+
// The plan helpers fall back to the one-time price only without a variant, which `variant` rules out.
|
|
228
|
+
const price = allocation ? (planPrice(variant, planId) ?? variant.price) : variant.price;
|
|
229
|
+
const compareAt = allocation?.priceAdjustments[0]?.compareAtPrice ?? variant.compareAtPrice;
|
|
230
|
+
const sale = {
|
|
231
|
+
line,
|
|
232
|
+
subscriptionAvailable: plans.some((plan) => allocationFor(variant, plan.id)),
|
|
233
|
+
compareAtPrice: compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount) ? compareAt : null,
|
|
234
|
+
savings: planSavings(variant, planId),
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
...sale,
|
|
238
|
+
variant,
|
|
239
|
+
selected: exact ? choice.selected : selectionOf(variant),
|
|
240
|
+
purchase,
|
|
241
|
+
plan: plans.find((plan) => plan.id === planId) ?? null,
|
|
242
|
+
quantity: choice.quantity,
|
|
243
|
+
price,
|
|
244
|
+
show: showConditions(variant, lowStock, product.variants.nodes.length, sale, product.requiresSellingPlan),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The `tether-bind` values that follow the variant and purchase. Thunks, so only the binds on the page are computed. */
|
|
249
|
+
export function variantBindTexts(view: ProductView, format: Formatters): Map<string, () => BindText> {
|
|
250
|
+
const { variant, plan, price, compareAtPrice, quantity } = view;
|
|
251
|
+
const planId = purchasePlanId(view.purchase);
|
|
252
|
+
// Prices for the quantity count one item when it is 0 (an optional bundle item).
|
|
253
|
+
const forQuantity = (money: Money) => timesQuantity(money, Math.max(1, quantity));
|
|
254
|
+
const checkoutCharge = () => {
|
|
255
|
+
const charge = view.line.kind === 'subscription' ? (planCheckoutCharge(variant, planId) ?? price) : price;
|
|
256
|
+
return format.money(forQuantity(charge));
|
|
257
|
+
};
|
|
258
|
+
const discountPercentage = () => {
|
|
259
|
+
if (!compareAtPrice) return '0%';
|
|
260
|
+
return `${Math.round((1 - parseFloat(price.amount) / parseFloat(compareAtPrice.amount)) * PERCENT)}%`;
|
|
261
|
+
};
|
|
262
|
+
const discountAmount = () => {
|
|
263
|
+
if (!compareAtPrice) return '';
|
|
264
|
+
return format.money(fromMinorUnits(toMinorUnits(compareAtPrice) - toMinorUnits(price), price.currencyCode));
|
|
265
|
+
};
|
|
266
|
+
const texts: Record<string, () => BindText> = {
|
|
267
|
+
price: () => format.money(price),
|
|
268
|
+
'total-price': () => format.money(forQuantity(price)),
|
|
269
|
+
'one-time-price': () => format.money(variant.price),
|
|
270
|
+
'subscription-price': () => format.money(planId ? planPrice(variant, planId) : null),
|
|
271
|
+
'subscription-savings': () => format.percent(view.savings.percentage),
|
|
272
|
+
'subscription-savings-amount': () => format.money(view.savings.amount),
|
|
273
|
+
'checkout-charge': checkoutCharge,
|
|
274
|
+
'plan-name': () => plan?.name,
|
|
275
|
+
'plan-description': () => plan?.description,
|
|
276
|
+
'plan-group': () => plan?.group.name,
|
|
277
|
+
'compare-at-price': () => format.money(compareAtPrice),
|
|
278
|
+
'unit-price': () => format.money(variant.unitPrice),
|
|
279
|
+
'discount-percentage': discountPercentage,
|
|
280
|
+
'discount-amount': discountAmount,
|
|
281
|
+
'variant-title': () => variant.title,
|
|
282
|
+
'variant-id': () => variant.id,
|
|
283
|
+
sku: () => variant.sku,
|
|
284
|
+
barcode: () => variant.barcode,
|
|
285
|
+
stock: () => variant.quantityAvailable,
|
|
286
|
+
quantity: () => quantity,
|
|
287
|
+
};
|
|
288
|
+
return new Map(Object.entries(texts));
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The `tether-plan-bind` values of one plan in a plan list, for the variant.
|
|
292
|
+
*
|
|
293
|
+
* @param planId The list item's `tether-plan-value`.
|
|
294
|
+
* @param plan The product's plan with that id.
|
|
295
|
+
*/
|
|
296
|
+
export function planBindTexts(
|
|
297
|
+
variant: ProductVariant,
|
|
298
|
+
planId: string,
|
|
299
|
+
plan: PlanInGroup | undefined,
|
|
300
|
+
format: Formatters,
|
|
301
|
+
): Map<string, () => BindText> {
|
|
302
|
+
const adjustmentText = () => {
|
|
303
|
+
const adjustment = planAdjustment(plan);
|
|
304
|
+
if (!adjustment) return '';
|
|
305
|
+
return adjustment.type === 'percentage' ? `${adjustment.value}%` : format.money(adjustment.value);
|
|
306
|
+
};
|
|
307
|
+
const texts: Record<string, () => BindText> = {
|
|
308
|
+
name: () => plan?.name,
|
|
309
|
+
// The option values alone ("1 month"), without the price text a store may put in the plan name.
|
|
310
|
+
option: () => (plan?.options ?? []).map((option) => option.value).join(', '),
|
|
311
|
+
description: () => plan?.description,
|
|
312
|
+
price: () => format.money(planPrice(variant, planId)),
|
|
313
|
+
savings: () => format.percent(planSavings(variant, planId).percentage),
|
|
314
|
+
'savings-amount': () => format.money(planSavings(variant, planId).amount),
|
|
315
|
+
adjustment: adjustmentText,
|
|
316
|
+
};
|
|
317
|
+
return new Map(Object.entries(texts));
|
|
318
|
+
}
|
package/src/product.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProductScope: everything inside [tether-product="<handle>"] binds to one Shopify product: options, variants,
|
|
3
|
+
* quantity, subscriptions and add to cart. The scope holds the visitor's choice and the actions on it;
|
|
4
|
+
* `product-state.ts` derives what the choice shows, `product-render.ts` and `variant-render.ts` write it, and
|
|
5
|
+
* `product-events.ts` turns clicks and changes into actions.
|
|
6
|
+
*/
|
|
7
|
+
import { cfg } from './config.ts';
|
|
8
|
+
import {
|
|
9
|
+
attr,
|
|
10
|
+
sel,
|
|
11
|
+
get,
|
|
12
|
+
has,
|
|
13
|
+
scoped,
|
|
14
|
+
emit,
|
|
15
|
+
setText,
|
|
16
|
+
notifyLayoutChanged,
|
|
17
|
+
watchImages,
|
|
18
|
+
parseJSONAttr,
|
|
19
|
+
attributesFromJson,
|
|
20
|
+
} from './dom.ts';
|
|
21
|
+
import { Cart } from './cart.ts';
|
|
22
|
+
import { allPlans, allocationFor, findPlan, GID } from './selling-plans.ts';
|
|
23
|
+
import {
|
|
24
|
+
initialChoice,
|
|
25
|
+
linePurchase,
|
|
26
|
+
normalisePurchase,
|
|
27
|
+
productView,
|
|
28
|
+
purchasePlanId,
|
|
29
|
+
selectionOf,
|
|
30
|
+
} from './product-state.ts';
|
|
31
|
+
import { writeProduct } from './product-render.ts';
|
|
32
|
+
import { writeVariant } from './variant-render.ts';
|
|
33
|
+
import { bindProductEvents } from './product-events.ts';
|
|
34
|
+
import type { Product, ProductVariant } from './fragments.ts';
|
|
35
|
+
import type { ShopifyCart } from './cart/store.ts';
|
|
36
|
+
import type { PlanInGroup } from './selling-plans.ts';
|
|
37
|
+
import type { Purchase, Selection } from './product-state.ts';
|
|
38
|
+
|
|
39
|
+
/** What a scope adds to the cart. */
|
|
40
|
+
interface CartLineInput {
|
|
41
|
+
merchandiseId: string;
|
|
42
|
+
quantity: number;
|
|
43
|
+
sellingPlanId?: string;
|
|
44
|
+
attributes?: { key: string; value: string }[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const scopes = new WeakMap<Element, ProductScope>();
|
|
48
|
+
|
|
49
|
+
const productSelector = () => sel('product');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Option names as written in the markup (the build's language), by product handle. In another language Shopify
|
|
53
|
+
* translates them (Size → Größe), so markup names that aren't the product's are matched by position.
|
|
54
|
+
*/
|
|
55
|
+
const knownOptionNames = new Map<string, string[]>();
|
|
56
|
+
|
|
57
|
+
export function rememberOptionNames(products: readonly Pick<Product, 'handle' | 'options'>[]): void {
|
|
58
|
+
products.forEach((product) => {
|
|
59
|
+
if (!knownOptionNames.has(product.handle)) {
|
|
60
|
+
knownOptionNames.set(
|
|
61
|
+
product.handle,
|
|
62
|
+
product.options.map((option) => option.name),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isInput = (el: Element): el is HTMLInputElement => el.tagName === 'INPUT';
|
|
69
|
+
|
|
70
|
+
const isField = (el: Element): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
71
|
+
el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param el A `tether-line-attribute` field.
|
|
75
|
+
* @returns Its value for the cart line; `null` for an unchecked or empty field.
|
|
76
|
+
*/
|
|
77
|
+
function lineAttributeValue(el: HTMLElement): string | null {
|
|
78
|
+
if (isInput(el) && el.type === 'checkbox') {
|
|
79
|
+
if (!el.checked) return null;
|
|
80
|
+
return el.value && el.value !== 'on' ? el.value : 'Yes';
|
|
81
|
+
}
|
|
82
|
+
if (isInput(el) && el.type === 'radio') return el.checked ? el.value : null;
|
|
83
|
+
return (isField(el) && el.value) || null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class ProductScope {
|
|
87
|
+
readonly root: HTMLElement;
|
|
88
|
+
product: Product;
|
|
89
|
+
plans: PlanInGroup[];
|
|
90
|
+
selected: Selection;
|
|
91
|
+
/** How the visitor buys; `setPurchase` and `selectPlan` change it. */
|
|
92
|
+
purchase: Purchase;
|
|
93
|
+
variant: ProductVariant | null;
|
|
94
|
+
quantity: number;
|
|
95
|
+
|
|
96
|
+
constructor(root: HTMLElement, product: Product) {
|
|
97
|
+
this.root = root;
|
|
98
|
+
this.product = product;
|
|
99
|
+
this.plans = allPlans(product);
|
|
100
|
+
const params = new URLSearchParams(window.location.search);
|
|
101
|
+
const choice = initialChoice(product, {
|
|
102
|
+
variant: params.get('variant') || get(root, 'variant'),
|
|
103
|
+
sellingPlan: params.get('selling_plan'),
|
|
104
|
+
defaultPlan: get(root, 'default-plan'),
|
|
105
|
+
defaultPurchase: get(root, 'default-purchase') || cfg.defaultPurchase,
|
|
106
|
+
});
|
|
107
|
+
this.selected = choice.selected;
|
|
108
|
+
this.purchase = choice.purchase;
|
|
109
|
+
this.variant = null;
|
|
110
|
+
this.quantity = has(root, 'quantity') ? Math.max(0, parseInt(get(root, 'quantity') ?? '', 10) || 0) : 1;
|
|
111
|
+
|
|
112
|
+
scopes.set(root, this);
|
|
113
|
+
root.setAttribute(attr('state'), 'ready');
|
|
114
|
+
root.removeAttribute(attr('error'));
|
|
115
|
+
this.renderProduct();
|
|
116
|
+
bindProductEvents(this);
|
|
117
|
+
this.renderVariant();
|
|
118
|
+
|
|
119
|
+
watchImages(root, 'product');
|
|
120
|
+
notifyLayoutChanged('product');
|
|
121
|
+
emit('product-ready', { product, scope: this }, root);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** New data for the same product (a country switch). Keeps the visitor's choice. */
|
|
125
|
+
update(product: Product): void {
|
|
126
|
+
const variantId = this.variant?.id;
|
|
127
|
+
this.product = product;
|
|
128
|
+
this.plans = allPlans(product);
|
|
129
|
+
const same = product.variants.nodes.find((variant) => variant.id === variantId);
|
|
130
|
+
if (same) this.selected = selectionOf(same);
|
|
131
|
+
this.root.setAttribute(attr('state'), 'ready');
|
|
132
|
+
this.root.removeAttribute(attr('error'));
|
|
133
|
+
this.renderProduct();
|
|
134
|
+
this.renderVariant();
|
|
135
|
+
notifyLayoutChanged('product');
|
|
136
|
+
emit('product-updated', { product, scope: this }, this.root);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @returns Matches in this scope, including the root itself (a card link with tether-bind="link"). */
|
|
140
|
+
$(selector: string): HTMLElement[] {
|
|
141
|
+
const inner = scoped(this.root, selector, productSelector());
|
|
142
|
+
return this.root.matches(selector) ? [this.root, ...inner] : inner;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param authored An option name in the markup.
|
|
147
|
+
* @returns The product's option of that name, or at that position in the build's language.
|
|
148
|
+
*/
|
|
149
|
+
optName(authored: string | null): string {
|
|
150
|
+
if (!authored) return '';
|
|
151
|
+
const { options } = this.product;
|
|
152
|
+
if (options.some((option) => option.name === authored)) return authored;
|
|
153
|
+
const index = (knownOptionNames.get(this.product.handle) ?? []).indexOf(authored);
|
|
154
|
+
return options[index]?.name ?? authored;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get plan(): PlanInGroup | null {
|
|
158
|
+
const planId = purchasePlanId(this.purchase);
|
|
159
|
+
return this.plans.find((plan) => plan.id === planId) ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when the current variant can be bought on a plan. */
|
|
163
|
+
get subscriptionAvailable(): boolean {
|
|
164
|
+
return this.plans.some((plan) => allocationFor(this.variant, plan.id));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when the line is bought on a plan: subscribing, and the variant is on the plan. */
|
|
168
|
+
get isSubscription(): boolean {
|
|
169
|
+
return linePurchase(this.purchase, this.variant).kind === 'subscription';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @returns What would be added to the cart right now. */
|
|
173
|
+
getLine(): CartLineInput | null {
|
|
174
|
+
if (!this.variant) return null;
|
|
175
|
+
const line: CartLineInput = { merchandiseId: this.variant.id, quantity: this.quantity };
|
|
176
|
+
const bought = linePurchase(this.purchase, this.variant);
|
|
177
|
+
if (bought.kind === 'subscription') line.sellingPlanId = bought.planId;
|
|
178
|
+
const attributes = this.collectAttributes();
|
|
179
|
+
if (attributes.length) line.attributes = attributes;
|
|
180
|
+
return line;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @returns `tether-line-attributes` and the filled `tether-line-attribute` fields. */
|
|
184
|
+
collectAttributes(): { key: string; value: string }[] {
|
|
185
|
+
const fixed = attributesFromJson(parseJSONAttr(this.root, 'line-attributes'));
|
|
186
|
+
const fields = this.$(sel('line-attribute')).flatMap((el) => {
|
|
187
|
+
const value = lineAttributeValue(el);
|
|
188
|
+
return value === null ? [] : [{ key: get(el, 'line-attribute') ?? '', value }];
|
|
189
|
+
});
|
|
190
|
+
return [...fixed, ...fields];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Selects an option value. A combination no variant has moves to the closest variant with the value: the most other
|
|
195
|
+
* choices kept, then one for sale.
|
|
196
|
+
*/
|
|
197
|
+
select(name: string, value: string): void {
|
|
198
|
+
if (this.selected[name] === value) return;
|
|
199
|
+
const withValue = this.product.variants.nodes.filter((variant) =>
|
|
200
|
+
variant.selectedOptions.some((option) => option.name === name && option.value === value),
|
|
201
|
+
);
|
|
202
|
+
const [firstWithValue] = withValue;
|
|
203
|
+
// A value no variant has: nothing to select, the visitor's choice stays.
|
|
204
|
+
if (!firstWithValue) return;
|
|
205
|
+
const next = { ...this.selected, [name]: value };
|
|
206
|
+
if (!withValue.some((variant) => variant.selectedOptions.every((option) => next[option.name] === option.value))) {
|
|
207
|
+
const score = (variant: ProductVariant) => {
|
|
208
|
+
const kept = variant.selectedOptions.filter(
|
|
209
|
+
(option) => option.name !== name && this.selected[option.name] === option.value,
|
|
210
|
+
).length;
|
|
211
|
+
// One kept choice outweighs being for sale.
|
|
212
|
+
return kept * 2 + (variant.availableForSale ? 1 : 0);
|
|
213
|
+
};
|
|
214
|
+
const closest = withValue.reduce(
|
|
215
|
+
(best, variant) => (score(variant) > score(best) ? variant : best),
|
|
216
|
+
firstWithValue,
|
|
217
|
+
);
|
|
218
|
+
Object.assign(next, selectionOf(closest));
|
|
219
|
+
}
|
|
220
|
+
this.selected = next;
|
|
221
|
+
this.renderVariant();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param id A variant gid or numeric id. */
|
|
225
|
+
selectVariant(id: string): void {
|
|
226
|
+
const variant = this.product.variants.nodes.find((candidate) => candidate.id === GID.variant(id));
|
|
227
|
+
if (!variant) return;
|
|
228
|
+
this.selected = selectionOf(variant);
|
|
229
|
+
this.renderVariant();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
setPurchase(kind: Purchase['kind']): void {
|
|
233
|
+
const next = normalisePurchase(this.product, this.variant, kind, purchasePlanId(this.purchase));
|
|
234
|
+
// The product decides when the wanted kind isn't possible (no plans, or a plan required).
|
|
235
|
+
if (next.kind === this.purchase.kind) return;
|
|
236
|
+
this.purchase = next;
|
|
237
|
+
this.renderVariant();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param ref A plan id, numeric id, name or option value. */
|
|
241
|
+
selectPlan(ref: string | null): void {
|
|
242
|
+
const plan = findPlan(this.product, ref);
|
|
243
|
+
if (!plan) return;
|
|
244
|
+
this.purchase = { kind: 'subscription', planId: plan.id };
|
|
245
|
+
this.renderVariant();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
setQuantity(quantity: number | string): void {
|
|
249
|
+
const minimum = has(this.root, 'quantity') ? 0 : 1;
|
|
250
|
+
this.quantity = Math.max(minimum, parseInt(String(quantity), 10) || 0);
|
|
251
|
+
this.$(sel('qty')).forEach((el) => {
|
|
252
|
+
setText(el, this.quantity);
|
|
253
|
+
});
|
|
254
|
+
this.renderVariant();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @returns The cart after adding; `null` when the variant can't be added. */
|
|
258
|
+
async addToCart(): Promise<ShopifyCart | null> {
|
|
259
|
+
if (!this.variant?.availableForSale) return null;
|
|
260
|
+
const line = this.getLine();
|
|
261
|
+
if (!line || line.quantity < 1) return null;
|
|
262
|
+
const cart = await Cart.add(line);
|
|
263
|
+
emit('product-added', { product: this.product, variant: this.variant, line }, this.root);
|
|
264
|
+
return cart;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Learns the markup's option names when they are the product's, then writes the product. */
|
|
268
|
+
renderProduct(): void {
|
|
269
|
+
const { product } = this;
|
|
270
|
+
if (!knownOptionNames.has(product.handle)) {
|
|
271
|
+
const authored = this.$(`${sel('option-list')},select${sel('option')}`).map(
|
|
272
|
+
(el) => get(el, 'option-list') || get(el, 'option'),
|
|
273
|
+
);
|
|
274
|
+
if (authored.some((name) => product.options.some((option) => option.name === name))) {
|
|
275
|
+
rememberOptionNames([product]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
writeProduct(this);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Derives the view of the current choice, keeps its variant, selection and purchase, and writes it. */
|
|
282
|
+
renderVariant(): void {
|
|
283
|
+
const view = productView(
|
|
284
|
+
this.product,
|
|
285
|
+
{ selected: this.selected, purchase: this.purchase, quantity: this.quantity },
|
|
286
|
+
cfg.lowStock,
|
|
287
|
+
);
|
|
288
|
+
if (!view) {
|
|
289
|
+
this.variant = null;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.variant = view.variant;
|
|
293
|
+
this.selected = view.selected;
|
|
294
|
+
this.purchase = view.purchase;
|
|
295
|
+
writeVariant(this, view);
|
|
296
|
+
emit(
|
|
297
|
+
'variant-changed',
|
|
298
|
+
{
|
|
299
|
+
product: this.product,
|
|
300
|
+
variant: view.variant,
|
|
301
|
+
selected: { ...view.selected },
|
|
302
|
+
purchase: view.purchase.kind,
|
|
303
|
+
sellingPlanId: view.line.kind === 'subscription' ? view.line.planId : null,
|
|
304
|
+
},
|
|
305
|
+
this.root,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|