@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.
- package/CHANGELOG.md +120 -0
- package/dist/account.css +4 -8
- package/dist/account.js +2387 -942
- package/dist/account.js.map +1 -1
- package/dist/{chunk-ZEFZPUAW.js → chunk-NBOIS53R.js} +2 -1
- package/dist/chunk-NBOIS53R.js.map +1 -0
- package/dist/index.d.ts +243 -29
- package/dist/index.js +221 -77
- package/dist/index.js.map +1 -1
- package/eject/account/account-availability.ts +4 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-referrals.ts +249 -7
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/dashboard.tsx +1 -0
- package/eject/account/downloads.tsx +73 -4
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +94 -31
- package/eject/account/messages/de.json +94 -31
- package/eject/account/messages/en.json +94 -31
- package/eject/account/messages/es.json +94 -31
- package/eject/account/messages/fi.json +94 -31
- package/eject/account/messages/fr.json +94 -31
- package/eject/account/messages/hi.json +94 -31
- package/eject/account/messages/it.json +94 -31
- package/eject/account/messages/lt.json +94 -31
- package/eject/account/messages/nl.json +94 -31
- package/eject/account/messages/pl.json +94 -31
- package/eject/account/messages/pt.json +94 -31
- package/eject/account/messages/ru.json +94 -31
- package/eject/account/messages/tr.json +94 -31
- package/eject/account/messages/uk.json +94 -31
- package/eject/account/messages/zh.json +94 -31
- package/eject/account/orders.tsx +141 -0
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +444 -156
- package/eject/account/reseller.tsx +59 -306
- package/eject/account/rewards.tsx +43 -6
- package/eject/account/settings.tsx +98 -2
- package/eject/account/shared.tsx +42 -3
- package/eject/account/subscriptions.tsx +75 -16
- package/package.json +7 -4
- 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
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* money
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* has been earned and says
|
|
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
|
|
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
|
-
|
|
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,
|