@shoppexio/storefront-react 0.2.1 → 0.4.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 (42) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/dist/account.css +4 -8
  3. package/dist/account.js +2387 -942
  4. package/dist/account.js.map +1 -1
  5. package/dist/{chunk-ZEFZPUAW.js → chunk-NBOIS53R.js} +2 -1
  6. package/dist/chunk-NBOIS53R.js.map +1 -0
  7. package/dist/index.d.ts +243 -29
  8. package/dist/index.js +221 -77
  9. package/dist/index.js.map +1 -1
  10. package/eject/account/account-availability.ts +4 -1
  11. package/eject/account/account-downloads.ts +89 -1
  12. package/eject/account/account-referrals.ts +249 -7
  13. package/eject/account/account-reseller.ts +6 -140
  14. package/eject/account/dashboard.tsx +1 -0
  15. package/eject/account/downloads.tsx +73 -4
  16. package/eject/account/hosted-page.ts +45 -0
  17. package/eject/account/messages/ar.json +94 -31
  18. package/eject/account/messages/de.json +94 -31
  19. package/eject/account/messages/en.json +94 -31
  20. package/eject/account/messages/es.json +94 -31
  21. package/eject/account/messages/fi.json +94 -31
  22. package/eject/account/messages/fr.json +94 -31
  23. package/eject/account/messages/hi.json +94 -31
  24. package/eject/account/messages/it.json +94 -31
  25. package/eject/account/messages/lt.json +94 -31
  26. package/eject/account/messages/nl.json +94 -31
  27. package/eject/account/messages/pl.json +94 -31
  28. package/eject/account/messages/pt.json +94 -31
  29. package/eject/account/messages/ru.json +94 -31
  30. package/eject/account/messages/tr.json +94 -31
  31. package/eject/account/messages/uk.json +94 -31
  32. package/eject/account/messages/zh.json +94 -31
  33. package/eject/account/orders.tsx +141 -0
  34. package/eject/account/overview.tsx +6 -0
  35. package/eject/account/referrals.tsx +444 -156
  36. package/eject/account/reseller.tsx +59 -306
  37. package/eject/account/rewards.tsx +43 -6
  38. package/eject/account/settings.tsx +98 -2
  39. package/eject/account/shared.tsx +42 -3
  40. package/eject/account/subscriptions.tsx +75 -16
  41. package/package.json +7 -4
  42. package/dist/chunk-ZEFZPUAW.js.map +0 -1
@@ -33,6 +33,7 @@ export interface AccountAvailabilitySource {
33
33
  subscriptionsCount: number;
34
34
  walletPresent: boolean;
35
35
  affiliateState: string;
36
+ affiliateCanCreateLinks: boolean;
36
37
  }
37
38
 
38
39
  export interface AccountAvailability {
@@ -88,7 +89,9 @@ export function accountAvailabilityFrom(
88
89
  subscriptions: source.subscriptionsCount > 0,
89
90
  wallet: source.walletPresent,
90
91
  rewards: true,
91
- referrals: source.affiliateState !== 'not_enrolled',
92
+ // With auto-join off a `not_enrolled` buyer may still make the first link,
93
+ // which is how they join; the panel's control has to be reachable for it.
94
+ referrals: source.affiliateState !== 'not_enrolled' || source.affiliateCanCreateLinks,
92
95
  favorites: true,
93
96
  reseller: accountResellerAvailable(reseller),
94
97
  };
@@ -1,4 +1,5 @@
1
- import type { SdkCustomerDeliverable, SdkCustomerLicense } from '@shoppexio/storefront-react';
1
+ import type { SdkCustomerDeliverable, SdkCustomerFileAccess, SdkCustomerLicense } from '@shoppexio/storefront-react';
2
+ import { getActiveLocale, t } from './i18n';
2
3
 
3
4
  /**
4
5
  * The downloads panel's pure half.
@@ -116,3 +117,90 @@ export function offersRenewal(license: SdkCustomerLicense): boolean {
116
117
  return license.renewable && license.expires_at !== null;
117
118
  }
118
119
 
120
+
121
+ /** What one file still allows, as the row says it (Design Batch 2, F1). */
122
+ export interface FileAccessCopy {
123
+ /** What is left and until when, or why it stopped. Null: nothing to say (unlimited, no end date). */
124
+ text: string | null;
125
+ /** The file no longer downloads, so the row offers no Download. */
126
+ refused: boolean;
127
+ /** The buyer can ask the shop to open it again. A refund closed it for a reason the shop already decided. */
128
+ canAsk: boolean;
129
+ }
130
+
131
+ /**
132
+ * A file's end date, as a day in the SHOP's calendar.
133
+ *
134
+ * The producer sends an instant and the shop's IANA zone. An end at 23:30 in
135
+ * Berlin is already the next day in UTC and still the same day in New York, so
136
+ * formatting in the viewer's zone would name a day the shop never set.
137
+ */
138
+ export function fileAccessDate(endsAt: string, timeZone: string): string {
139
+ const date = new Date(endsAt);
140
+ if (Number.isNaN(date.getTime())) return endsAt;
141
+ return new Intl.DateTimeFormat(getActiveLocale(), {
142
+ year: 'numeric',
143
+ month: 'short',
144
+ day: 'numeric',
145
+ timeZone,
146
+ }).format(date);
147
+ }
148
+
149
+ /**
150
+ * The same sentences as the hosted portal's `describeFileAccess`. An open file
151
+ * names only the parts the producer sent: no limit and no end date says
152
+ * nothing at all rather than "unlimited".
153
+ */
154
+ export function describeFileAccess(access: SdkCustomerFileAccess): FileAccessCopy {
155
+ const until = access.ends_at ? fileAccessDate(access.ends_at, access.time_zone) : null;
156
+ switch (access.state) {
157
+ case 'open': {
158
+ const parts: string[] = [];
159
+ if (access.downloads_left !== null) {
160
+ parts.push(t(
161
+ access.downloads_left === 1 ? 'downloads.accessLeftOne' : 'downloads.accessLeftOther',
162
+ { count: access.downloads_left },
163
+ ));
164
+ }
165
+ if (until) parts.push(t('downloads.accessUntil', { date: until }));
166
+ return { text: parts.length > 0 ? parts.join(' · ') : null, refused: false, canAsk: false };
167
+ }
168
+ case 'limit_reached':
169
+ return {
170
+ text: access.download_limit === null
171
+ ? t('downloads.accessLimitReached')
172
+ : t(
173
+ access.download_limit === 1 ? 'downloads.accessLimitReachedOne' : 'downloads.accessLimitReachedOther',
174
+ { count: access.download_limit },
175
+ ),
176
+ refused: true,
177
+ canAsk: true,
178
+ };
179
+ case 'expired':
180
+ return {
181
+ text: until ? t('downloads.accessExpired', { date: until }) : t('downloads.accessExpiredUndated'),
182
+ refused: true,
183
+ canAsk: true,
184
+ };
185
+ case 'revoked':
186
+ return { text: t('downloads.accessRevoked'), refused: true, canAsk: true };
187
+ case 'refunded':
188
+ return { text: t('downloads.accessRefunded'), refused: true, canAsk: false };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The ticket "Ask for access" opens: on the order, naming the file and why it
194
+ * stopped, so the shop can extend, reset or restore it from the order page.
195
+ */
196
+ export function accessRequestTicket(
197
+ file: Pick<SdkCustomerDeliverable, 'label' | 'product_title' | 'invoice_uniqid'>,
198
+ reason: string | null,
199
+ ): { title: string; message: string; invoice_id: string } {
200
+ const message = t('downloads.accessRequestMessage', { file: file.label, product: file.product_title });
201
+ return {
202
+ title: t('downloads.accessRequestTitle', { file: file.label }).slice(0, 120),
203
+ message: reason ? `${message} ${t('downloads.accessRequestReason', { reason })}` : message,
204
+ invoice_id: file.invoice_uniqid,
205
+ };
206
+ }
@@ -1,14 +1,15 @@
1
- import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
1
+ import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateRates, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
2
2
 
3
3
  /**
4
4
  * The referrals panel's pure half.
5
5
  *
6
- * Read-only by design. Converting a referral balance into store credit and
7
- * requesting a payout both move money toward the buyer, and ADR-0064 keeps
8
- * money paths off the account API for good — theme code shares the origin with
9
- * the session cookie, so an allowlisted payout endpoint would be a transaction
10
- * primitive sitting next to untrusted markup. The panel therefore shows what
11
- * has been earned and says where the payout is arranged.
6
+ * One write: a link with a code of the buyer's own, when the shop allows it.
7
+ * Converting a referral balance into store credit and requesting a payout both
8
+ * move money toward the buyer, and ADR-0064 keeps money paths off the account
9
+ * API for good — theme code shares the origin with the session cookie, so an
10
+ * allowlisted payout endpoint would be a transaction primitive sitting next to
11
+ * untrusted markup. The panel therefore shows what has been earned and says
12
+ * where the payout is arranged.
12
13
  *
13
14
  * Nothing here adds two balances together either. The server sends five
14
15
  * figures; summing them into a friendlier one produces a number no other
@@ -27,6 +28,18 @@ export function isEnrolledInReferrals(summary: SdkCustomerAffiliateSummary): boo
27
28
  return summary.state !== 'not_enrolled';
28
29
  }
29
30
 
31
+ /**
32
+ * Whether the links section has anything to offer.
33
+ *
34
+ * Enrollment and link creation are separate answers: with auto-join off the
35
+ * shop reports `not_enrolled` and still lets the buyer make their first link,
36
+ * which is what enrolls them. Only when it allows neither is there nothing
37
+ * but the state to say.
38
+ */
39
+ export function offersReferralLinks(summary: SdkCustomerAffiliateSummary): boolean {
40
+ return isEnrolledInReferrals(summary) || summary.can_create_custom_links;
41
+ }
42
+
30
43
  /**
31
44
  * The link a buyer shares, or null when the server built none.
32
45
  *
@@ -44,6 +57,16 @@ export function isAwaitingApproval(link: SdkCustomerAffiliateLink): boolean {
44
57
  return link.status === 'pending';
45
58
  }
46
59
 
60
+ /**
61
+ * Whether the link may be handed out. Checkout resolves only an approved,
62
+ * active link (`CouponService.resolveAffiliateCodeAsCoupon`), so a pending,
63
+ * rejected or switched-off one would send buyers who get no discount and earn
64
+ * the affiliate nothing.
65
+ */
66
+ export function isShareableLink(link: SdkCustomerAffiliateLink): boolean {
67
+ return link.status === 'active';
68
+ }
69
+
47
70
  const CLAIM_TYPE_KEYS: Readonly<Record<string, string>> = {
48
71
  STORE_CREDIT: 'referrals.claimStoreCredit',
49
72
  CRYPTO_PAYOUT_REQUEST: 'referrals.claimCryptoPayout',
@@ -66,3 +89,222 @@ export function isForeignBalanceCurrency(
66
89
  return summary.balance_currency.toUpperCase() !== shopCurrency.toUpperCase();
67
90
  }
68
91
 
92
+
93
+ type AffiliateCommission = SdkCustomerAffiliateSummary['commissions'][number];
94
+ type CommissionLedgerStatus = AffiliateCommission['ledger_status'];
95
+
96
+ const LEDGER_STATUS_KEYS: Readonly<Record<CommissionLedgerStatus, string>> = {
97
+ waiting_payment: 'referrals.ledgerWaitingPayment',
98
+ earned: 'referrals.ledgerEarned',
99
+ in_payout_request: 'referrals.ledgerInPayoutRequest',
100
+ paid_out: 'referrals.ledgerPaidOut',
101
+ store_credit: 'referrals.ledgerStoreCredit',
102
+ split: 'referrals.ledgerSplit',
103
+ voided: 'referrals.ledgerVoided',
104
+ };
105
+
106
+ /** The shared status word whose chip tone each ledger status borrows. */
107
+ const LEDGER_STATUS_TONES: Readonly<Record<CommissionLedgerStatus, string>> = {
108
+ waiting_payment: 'PENDING',
109
+ earned: 'APPROVED',
110
+ in_payout_request: 'REQUESTED',
111
+ paid_out: 'PAID',
112
+ store_credit: 'CONVERTED',
113
+ split: 'PAID',
114
+ voided: 'VOIDED',
115
+ };
116
+
117
+ /**
118
+ * The label key for a commission's ledger status.
119
+ *
120
+ * `status` is the raw row state and stays APPROVED after the money has left
121
+ * the ledger; `ledger_status` says whether it was paid out, became store
122
+ * credit or went both ways (`split`), which is what the buyer asks.
123
+ */
124
+ export function commissionLedgerKey(status: CommissionLedgerStatus): string {
125
+ return LEDGER_STATUS_KEYS[status];
126
+ }
127
+
128
+ export function commissionLedgerTone(status: CommissionLedgerStatus): string {
129
+ return LEDGER_STATUS_TONES[status];
130
+ }
131
+
132
+ export interface CommissionAmounts {
133
+ /** The figure the row leads with. */
134
+ amount: string;
135
+ /** The original commission, or null when `amount` already is all of it. */
136
+ total: string | null;
137
+ /** The part paid out, or null when there is none or `amount` already is it. */
138
+ paidOut: string | null;
139
+ /** The part turned into store credit, or null when there is none or `amount` already is it. */
140
+ storeCredit: string | null;
141
+ }
142
+
143
+ /**
144
+ * What a commission row shows as money.
145
+ *
146
+ * `commission_amount` is what the sale earned in full. An earned row leads
147
+ * with what is still earned, a payout-request row with `requested_amount`,
148
+ * a paid-out row with `paid_amount`, a store-credit
149
+ * row with `store_credit_amount`. Every other destination part is named on its
150
+ * own: a split commission is "20 paid out, 5 store credit", never one settled
151
+ * sum.
152
+ */
153
+ export function commissionAmounts(commission: AffiliateCommission): CommissionAmounts {
154
+ const status = commission.ledger_status;
155
+ const amount = status === 'earned'
156
+ ? commission.earned_amount
157
+ : status === 'in_payout_request'
158
+ ? commission.requested_amount
159
+ : status === 'paid_out'
160
+ ? commission.paid_amount
161
+ : status === 'store_credit'
162
+ ? commission.store_credit_amount
163
+ : commission.commission_amount;
164
+ return {
165
+ amount,
166
+ total: Number(amount) !== Number(commission.commission_amount) ? commission.commission_amount : null,
167
+ paidOut: status !== 'paid_out' && Number(commission.paid_amount) > 0 ? commission.paid_amount : null,
168
+ storeCredit: status !== 'store_credit' && Number(commission.store_credit_amount) > 0
169
+ ? commission.store_credit_amount
170
+ : null,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * A rate the buyer asks for, read strictly.
176
+ *
177
+ * Empty is "the shop's own rate" and answers null. Anything else has to be a
178
+ * plain percentage between 0 and 100 with at most two decimals: `Number()`
179
+ * would take `1e2` for 100 and `0x10` for 16, and a rate the buyer did not mean
180
+ * to ask for is a link sitting in the shop's approval queue under the wrong
181
+ * terms. `undefined` is "not a rate".
182
+ */
183
+ export function parseRequestedRate(raw: string): number | null | undefined {
184
+ const value = raw.trim();
185
+ if (value === '') return null;
186
+ // A decimal comma is how German or French keyboards type 12.5, so either
187
+ // separator is read as the decimal point. Only one may appear, with one or
188
+ // two digits after it: `1,234` or `1.234,5` could be a thousands group and is
189
+ // refused rather than guessed at.
190
+ if (!/^\d{1,3}([.,]\d{1,2})?$/.test(value)) return undefined;
191
+ const rate = Number(value.replace(',', '.'));
192
+ return rate <= 100 ? rate : undefined;
193
+ }
194
+
195
+ export interface ReferralLinkDraft {
196
+ label: string;
197
+ code: string;
198
+ commission: string;
199
+ discount: string;
200
+ }
201
+
202
+ export type ReferralLinkInputResult =
203
+ | {
204
+ ok: true;
205
+ input: {
206
+ label: string;
207
+ code: string;
208
+ requested_affiliate_revenue_percent?: number;
209
+ requested_affiliate_customer_discount_percent?: number;
210
+ };
211
+ }
212
+ | { ok: false; reason: 'required' | 'rate' };
213
+
214
+ /**
215
+ * What the new-link form sends, or why it sends nothing.
216
+ *
217
+ * The producer owns every rule about a code — its length, whether it is taken,
218
+ * how many links a buyer may hold — and answers with its own sentence. Two
219
+ * things are settled here first: both required fields carry something, and a
220
+ * requested rate is a rate. A rate left empty is left OUT: a present one puts
221
+ * the link in the shop's approval queue, an absent one is the shop's default.
222
+ */
223
+ export function referralLinkInput(draft: ReferralLinkDraft): ReferralLinkInputResult {
224
+ const label = draft.label.trim();
225
+ const code = draft.code.trim();
226
+ if (label === '' || code === '') return { ok: false, reason: 'required' };
227
+
228
+ const commission = parseRequestedRate(draft.commission);
229
+ const discount = parseRequestedRate(draft.discount);
230
+ if (commission === undefined || discount === undefined) return { ok: false, reason: 'rate' };
231
+
232
+ return {
233
+ ok: true,
234
+ input: {
235
+ label,
236
+ code,
237
+ ...(commission !== null ? { requested_affiliate_revenue_percent: commission } : {}),
238
+ ...(discount !== null ? { requested_affiliate_customer_discount_percent: discount } : {}),
239
+ },
240
+ };
241
+ }
242
+
243
+ /** A catalog key and its values, translated by the panel. */
244
+ export interface ReferralMessage {
245
+ key: string;
246
+ params?: Record<string, string>;
247
+ }
248
+
249
+ export interface ReferralRateRow {
250
+ id: 'rate' | 'order' | 'binding';
251
+ title: ReferralMessage;
252
+ detail: ReferralMessage | null;
253
+ }
254
+
255
+ /**
256
+ * "Your rates" (Design Batch 3 F2), the same rows as the Easy-lane account
257
+ * block: the rate and the products that earn nothing, the cap and minimum per
258
+ * order, and how long a referred buyer stays. A part shows only when the shop
259
+ * set it. One rate per link was not true once products, a cap and a minimum
260
+ * could pay less.
261
+ */
262
+ export function referralRateRows(
263
+ rates: SdkCustomerAffiliateRates,
264
+ format: {
265
+ money: (usd: string) => string;
266
+ /** "A, B and C" in the reader's language. */
267
+ list: (names: string[]) => string;
268
+ /** "2 more", for the No Commission products past the three named. */
269
+ more: (count: number) => string;
270
+ },
271
+ ): ReferralRateRow[] {
272
+ const percent = String(rates.percent);
273
+ const more = rates.disabled_product_count - rates.disabled_products.length;
274
+ const names = more > 0 ? [...rates.disabled_products, format.more(more)] : rates.disabled_products;
275
+ const disabled: ReferralMessage | null = rates.disabled_product_count === 0
276
+ ? null
277
+ : rates.disabled_product_count === 1
278
+ ? { key: 'referrals.ratesProductEarnsNothing', params: { product: format.list(names) } }
279
+ : { key: 'referrals.ratesProductsEarnNothing', params: { products: format.list(names) } };
280
+ // A product with its own rate pays that instead of this one, so "every product" would overpromise.
281
+ const customRate: ReferralMessage | null = rates.custom_rate_product_count === 0 ? null : { key: 'referrals.ratesCustomRateProducts' };
282
+ const rows: ReferralRateRow[] = [{
283
+ id: 'rate',
284
+ title: { key: disabled || customRate ? 'referrals.ratesMostProducts' : 'referrals.ratesEveryProduct', params: { percent } },
285
+ detail: disabled ?? customRate,
286
+ }];
287
+ const minimum: ReferralMessage | null = rates.minimum_order_usd === null
288
+ ? null
289
+ : { key: 'referrals.ratesMinimum', params: { amount: format.money(rates.minimum_order_usd) } };
290
+ if (rates.cap_usd !== null) {
291
+ rows.push({ id: 'order', title: { key: 'referrals.ratesCap', params: { amount: format.money(rates.cap_usd) } }, detail: minimum });
292
+ } else if (minimum) {
293
+ rows.push({ id: 'order', title: minimum, detail: null });
294
+ }
295
+ if (rates.binding_mode !== 'PER_ORDER') {
296
+ rows.push({
297
+ id: 'binding',
298
+ title: rates.binding_days === null
299
+ ? { key: 'referrals.ratesBindingForever' }
300
+ : { key: 'referrals.ratesBindingDays', params: { days: String(rates.binding_days) } },
301
+ detail: { key: 'referrals.ratesBindingDetail' },
302
+ });
303
+ }
304
+ return rows;
305
+ }
306
+
307
+ /** A link names its rate only when it earns another one than "Your rates" says. */
308
+ export function linkCommissionDiffers(link: SdkCustomerAffiliateLink, rates: SdkCustomerAffiliateRates | null): boolean {
309
+ return rates === null || link.effective_commission_percent !== rates.percent;
310
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure wire narrowing and basket quote helpers for the wholesale account tab.
2
+ * Pure wire narrowing for the wholesale account tab.
3
3
  *
4
4
  * The shapes below follow the mapper functions in
5
5
  * `apps/backend-elysia/src/routes/v1/customer-reseller.ts` exactly. They do not
@@ -38,6 +38,8 @@ export interface ResellerProgramState {
38
38
  enrollment_mode: ResellerEnrollmentMode;
39
39
  application_message: string | null;
40
40
  require_application_note: boolean;
41
+ /** The shop hands out delivery URLs; the "Delivery URL" link shows only then. */
42
+ delivery_url_enabled: boolean;
41
43
  reseller: ResellerRelationship | null;
42
44
  }
43
45
 
@@ -45,7 +47,6 @@ export interface ResellerCatalogVariant {
45
47
  variant_id: string;
46
48
  title: string;
47
49
  stock: number;
48
- supplier_backed: boolean;
49
50
  orderable: boolean;
50
51
  base_price: string;
51
52
  unit_price: string;
@@ -59,7 +60,6 @@ export interface ResellerCatalogItem {
59
60
  title: string;
60
61
  type: string;
61
62
  stock: number;
62
- supplier_backed: boolean;
63
63
  orderable: boolean;
64
64
  volume_discount_enabled: boolean;
65
65
  base_price: string;
@@ -82,13 +82,7 @@ export interface ResellerCatalogPayload {
82
82
  catalog: ResellerCatalog;
83
83
  }
84
84
 
85
- export interface ResellerOrderItemInput {
86
- product_id: string;
87
- variant_id?: string;
88
- quantity: number;
89
- }
90
-
91
- /** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions. */
85
+ /** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions; an order line is read against them. */
92
86
  export const RESELLER_QUANTITY_MIN_UNITS = 1;
93
87
  export const RESELLER_QUANTITY_MAX_UNITS = 100_000;
94
88
 
@@ -98,31 +92,6 @@ export function isValidResellerQuantity(value: number): boolean {
98
92
  && value <= RESELLER_QUANTITY_MAX_UNITS;
99
93
  }
100
94
 
101
- export interface ResellerQuoteItem {
102
- product_id: string;
103
- product_title: string;
104
- product_type: string;
105
- variant_id: string | null;
106
- variant_title: string | null;
107
- quantity: number;
108
- base_price: string;
109
- discount_percent: number;
110
- unit_price: string;
111
- line_total: string;
112
- applied_override: ResellerAppliedOverride;
113
- volume_discount_applied: string;
114
- }
115
-
116
- export interface ResellerQuote {
117
- currency: string;
118
- tier: { id: string; name: string; discount_percent: number };
119
- items: ResellerQuoteItem[];
120
- subtotal: string;
121
- volume_discount_total: string;
122
- total: string;
123
- savings: string;
124
- }
125
-
126
95
  export interface ResellerOrderListItem {
127
96
  uniqid: string;
128
97
  status: string;
@@ -287,6 +256,7 @@ export function narrowResellerProgram(
287
256
  !isEnrollmentMode(payload.enrollment_mode) ||
288
257
  !isNullableString(payload.application_message) ||
289
258
  typeof payload.require_application_note !== "boolean" ||
259
+ typeof payload.delivery_url_enabled !== "boolean" ||
290
260
  reseller === undefined
291
261
  ) {
292
262
  return { ok: false };
@@ -298,6 +268,7 @@ export function narrowResellerProgram(
298
268
  enrollment_mode: payload.enrollment_mode,
299
269
  application_message: payload.application_message,
300
270
  require_application_note: payload.require_application_note,
271
+ delivery_url_enabled: payload.delivery_url_enabled,
301
272
  reseller,
302
273
  },
303
274
  };
@@ -318,7 +289,6 @@ function narrowCatalogVariant(value: unknown): ResellerCatalogVariant | null {
318
289
  typeof value.variant_id !== "string" ||
319
290
  typeof value.title !== "string" ||
320
291
  !isFiniteNumber(value.stock) ||
321
- typeof value.supplier_backed !== "boolean" ||
322
292
  typeof value.orderable !== "boolean" ||
323
293
  typeof value.base_price !== "string" ||
324
294
  typeof value.unit_price !== "string" ||
@@ -343,7 +313,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
343
313
  typeof value.title !== "string" ||
344
314
  typeof value.type !== "string" ||
345
315
  !isFiniteNumber(value.stock) ||
346
- typeof value.supplier_backed !== "boolean" ||
347
316
  typeof value.orderable !== "boolean" ||
348
317
  typeof value.volume_discount_enabled !== "boolean" ||
349
318
  typeof value.base_price !== "string" ||
@@ -358,7 +327,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
358
327
  title: value.title,
359
328
  type: value.type,
360
329
  stock: value.stock,
361
- supplier_backed: value.supplier_backed,
362
330
  orderable: value.orderable,
363
331
  volume_discount_enabled: value.volume_discount_enabled,
364
332
  base_price: value.base_price,
@@ -412,69 +380,6 @@ export function narrowResellerCatalog(
412
380
  };
413
381
  }
414
382
 
415
- function narrowQuoteItem(value: unknown): ResellerQuoteItem | null {
416
- if (!isRecord(value)) return null;
417
- if (
418
- typeof value.product_id !== "string" ||
419
- typeof value.product_title !== "string" ||
420
- typeof value.product_type !== "string" ||
421
- !isNullableString(value.variant_id) ||
422
- !isNullableString(value.variant_title) ||
423
- !isValidResellerQuantity(value.quantity as number) ||
424
- typeof value.base_price !== "string" ||
425
- !isFiniteNumber(value.discount_percent) ||
426
- typeof value.unit_price !== "string" ||
427
- typeof value.line_total !== "string" ||
428
- !isAppliedOverride(value.applied_override) ||
429
- typeof value.volume_discount_applied !== "string"
430
- )
431
- return null;
432
- return value as unknown as ResellerQuoteItem;
433
- }
434
-
435
- /** `POST /reseller/orders/quote`, written against mapQuote. */
436
- export function narrowResellerQuote(
437
- payload: unknown,
438
- ): NarrowResult<ResellerQuote> {
439
- if (!isRecord(payload) || !isRecord(payload.quote)) return { ok: false };
440
- const raw = payload.quote;
441
- const tier = narrowTier(raw.tier);
442
- if (
443
- !tier ||
444
- typeof tier.name !== "string" ||
445
- typeof tier.discount_percent !== "number" ||
446
- typeof raw.currency !== "string" ||
447
- !Array.isArray(raw.items) ||
448
- typeof raw.subtotal !== "string" ||
449
- typeof raw.volume_discount_total !== "string" ||
450
- typeof raw.total !== "string" ||
451
- typeof raw.savings !== "string"
452
- )
453
- return { ok: false };
454
- const items: ResellerQuoteItem[] = [];
455
- for (const entry of raw.items) {
456
- const item = narrowQuoteItem(entry);
457
- if (!item) return { ok: false };
458
- items.push(item);
459
- }
460
- return {
461
- ok: true,
462
- data: {
463
- currency: raw.currency,
464
- tier: {
465
- id: tier.id,
466
- name: tier.name,
467
- discount_percent: tier.discount_percent,
468
- },
469
- items,
470
- subtotal: raw.subtotal,
471
- volume_discount_total: raw.volume_discount_total,
472
- total: raw.total,
473
- savings: raw.savings,
474
- },
475
- };
476
- }
477
-
478
383
  function narrowOrderListItem(value: unknown): ResellerOrderListItem | null {
479
384
  if (!isRecord(value)) return null;
480
385
  if (
@@ -627,35 +532,6 @@ export function narrowResellerApiKeys(
627
532
  return { ok: true, data: keys };
628
533
  }
629
534
 
630
- /** Stable identity of one basket version. Order is normalized for comparison. */
631
- export function resellerBasketKey(
632
- items: readonly ResellerOrderItemInput[],
633
- ): string {
634
- for (const item of items) {
635
- if (!isValidResellerQuantity(item.quantity)) {
636
- throw new RangeError(
637
- `Wholesale quantity must be a whole number from ${RESELLER_QUANTITY_MIN_UNITS} to ${RESELLER_QUANTITY_MAX_UNITS} units; received ${String(item.quantity)}.`,
638
- );
639
- }
640
- }
641
-
642
- return JSON.stringify(
643
- [...items]
644
- .map((item) => ({
645
- product_id: item.product_id,
646
- variant_id: item.variant_id ?? null,
647
- quantity: item.quantity,
648
- }))
649
- .sort((left, right) => {
650
- const product = left.product_id.localeCompare(right.product_id);
651
- return (
652
- product ||
653
- (left.variant_id ?? "").localeCompare(right.variant_id ?? "")
654
- );
655
- }),
656
- );
657
- }
658
-
659
535
  /**
660
536
  * The one way into the program this buyer actually has.
661
537
  *
@@ -673,13 +549,3 @@ export function resellerEntryMode(
673
549
  ? "MANUAL"
674
550
  : program.enrollment_mode;
675
551
  }
676
-
677
- /** A quote is displayed only for the basket it priced. */
678
- export function quoteMatchesBasket(
679
- quotedBasketKey: string | null,
680
- items: readonly ResellerOrderItemInput[],
681
- ): boolean {
682
- return (
683
- quotedBasketKey !== null && quotedBasketKey === resellerBasketKey(items)
684
- );
685
- }
@@ -118,6 +118,7 @@ export function useAccountAvailability(reads: AccountShellReads): AccountAvailab
118
118
  subscriptionsCount: dashboard.state.data.subscriptions.length,
119
119
  walletPresent: dashboard.state.data.wallet !== null,
120
120
  affiliateState: dashboard.state.data.affiliate.state,
121
+ affiliateCanCreateLinks: dashboard.state.data.affiliate.can_create_custom_links,
121
122
  }
122
123
  : null,
123
124
  program?.ok ? program.data : null,