@shoppexio/storefront-react 0.0.0 → 0.1.1

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 (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. package/package.json +79 -1
@@ -0,0 +1,104 @@
1
+ import type { SdkCustomerRewardActivity } from '@shoppexio/storefront-react';
2
+
3
+ /**
4
+ * The rewards panel's pure half.
5
+ *
6
+ * Rewards arrive inside the dashboard payload, already summed by the server.
7
+ * Nothing here recomputes a balance: the available figure is the buyer's wallet
8
+ * minus what is on hold, and a storefront adding up the activity list would
9
+ * produce a second, different number for the same money.
10
+ */
11
+
12
+ /**
13
+ * The coupons a buyer can actually use.
14
+ *
15
+ * All three conditions matter. A pending coupon has no code yet, a revoked one
16
+ * has a code that will be refused at checkout, and a wallet grant is credit
17
+ * rather than something to paste. Any of them in this list would send a buyer
18
+ * to checkout with a code that does not work.
19
+ */
20
+ export interface UsableCoupon extends SdkCustomerRewardActivity {
21
+ /** Narrowed: a coupon without a code is not in this list at all. */
22
+ coupon_code: string;
23
+ }
24
+
25
+ export function usableCoupons(
26
+ activity: readonly SdkCustomerRewardActivity[],
27
+ ): UsableCoupon[] {
28
+ const coupons: UsableCoupon[] = [];
29
+ for (const item of activity) {
30
+ if (item.type !== 'COUPON' || item.status !== 'FULFILLED') continue;
31
+ if (typeof item.coupon_code !== 'string' || item.coupon_code.length === 0) continue;
32
+ coupons.push({ ...item, coupon_code: item.coupon_code });
33
+ }
34
+ return coupons;
35
+ }
36
+
37
+ /**
38
+ * What one reward IS, decided without a word of prose.
39
+ *
40
+ * The panel turns this into a sentence, because only the panel knows the
41
+ * locale and how this shop formats money. Store credit names its amount only
42
+ * when the server sent BOTH the amount and the currency: a number without its
43
+ * currency is not money, and printing one beside a currency it was not measured
44
+ * in is how a buyer reads €5 as $5.
45
+ */
46
+ export type RewardTitle =
47
+ | { kind: 'store-credit-amount'; amount: string; currency: string }
48
+ | { kind: 'store-credit' }
49
+ | { kind: 'coupon-code'; code: string }
50
+ | { kind: 'coupon-percent'; percent: string }
51
+ | { kind: 'coupon' }
52
+ | { kind: 'other' };
53
+
54
+ export function rewardTitle(item: SdkCustomerRewardActivity): RewardTitle {
55
+ if (item.type === 'WALLET_CREDIT') {
56
+ return item.amount && item.currency
57
+ ? { kind: 'store-credit-amount', amount: item.amount, currency: item.currency }
58
+ : { kind: 'store-credit' };
59
+ }
60
+
61
+ if (item.type === 'COUPON') {
62
+ if (item.coupon_code) return { kind: 'coupon-code', code: item.coupon_code };
63
+ if (item.amount) return { kind: 'coupon-percent', percent: item.amount };
64
+ return { kind: 'coupon' };
65
+ }
66
+
67
+ return { kind: 'other' };
68
+ }
69
+
70
+ const REASON_KEYS: Readonly<Record<string, string>> = {
71
+ ORDER_COMPLETED: 'rewards.reasonOrderCompleted',
72
+ ORDER_COUNT_REACHED: 'rewards.reasonOrderCount',
73
+ SPEND_AMOUNT_REACHED: 'rewards.reasonSpendAmount',
74
+ POSITIVE_REVIEW_LEFT: 'rewards.reasonReview',
75
+ FIRST_PURCHASE_COMPLETED: 'rewards.reasonFirstPurchase',
76
+ };
77
+
78
+ /** Why the buyer earned it. An unknown trigger says "Reward", never its enum. */
79
+ export function rewardReasonKey(reason: string): string {
80
+ return REASON_KEYS[reason] ?? 'rewards.reasonOther';
81
+ }
82
+
83
+ const GRANT_STATUS_KEYS: Readonly<Record<string, string>> = {
84
+ FULFILLED: 'rewards.grantFulfilled',
85
+ PENDING: 'rewards.grantPending',
86
+ FAILED: 'rewards.grantFailed',
87
+ REVOKED: 'rewards.grantRevoked',
88
+ };
89
+
90
+ /** Null for a status this catalog has never heard of — the caller shows the raw value. */
91
+ export function rewardStatusKey(status: string): string | null {
92
+ return GRANT_STATUS_KEYS[status] ?? null;
93
+ }
94
+
95
+ /** Epoch SECONDS — the unit `RewardReadService` emits — to a date. */
96
+ export function epochSecondsToDate(value: number): Date {
97
+ return new Date(value * 1000);
98
+ }
99
+
100
+ /** The same, for the fields where `null` means "no date", not "unreadable". */
101
+ export function optionalEpochSecondsToDate(value: number | null): Date | null {
102
+ return value === null ? null : epochSecondsToDate(value);
103
+ }
104
+
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The settings panel's pure half: the two payloads it narrows, and the small
3
+ * rules around what the buyer may change.
4
+ *
5
+ * What is deliberately absent: the email address (changing it is gated behind a
6
+ * fresh code in the backend and has no allowlisted path), a password field
7
+ * (this account signs in with a code, and `/security/password` is not open to
8
+ * storefront code), and account deletion (there is no endpoint at all). A
9
+ * control for any of them would be a control that cannot work.
10
+ */
11
+
12
+ export interface EmailList {
13
+ id: string;
14
+ name: string;
15
+ description: string | null;
16
+ subscribed: boolean;
17
+ }
18
+
19
+ export interface EmailPreferences {
20
+ /** True when the buyer is suppressed from every marketing send. */
21
+ global_unsubscribed: boolean;
22
+ lists: EmailList[];
23
+ }
24
+
25
+ export type EmailPreferencesResult =
26
+ | { ok: true; preferences: EmailPreferences }
27
+ | { ok: false };
28
+
29
+ export interface CustomerSession {
30
+ id: string;
31
+ created_at: string;
32
+ expires_at: string;
33
+ ip_address: string | null;
34
+ user_agent: string | null;
35
+ /** The session making the request. Never offered a "sign out" of its own. */
36
+ current: boolean;
37
+ }
38
+
39
+ export type CustomerSessionsResult =
40
+ | { ok: true; sessions: CustomerSession[] }
41
+ | { ok: false };
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function readNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
48
+ const value = record[key];
49
+ if (value === null || typeof value === 'string') return value;
50
+ return undefined;
51
+ }
52
+
53
+ export function narrowEmailPreferences(payload: unknown): EmailPreferencesResult {
54
+ if (!isRecord(payload) || typeof payload.global_unsubscribed !== 'boolean') return { ok: false };
55
+ if (!Array.isArray(payload.lists)) return { ok: false };
56
+
57
+ const lists: EmailList[] = [];
58
+ for (const entry of payload.lists) {
59
+ if (!isRecord(entry)) return { ok: false };
60
+ const description = readNullableString(entry, 'description');
61
+ if (
62
+ typeof entry.id !== 'string'
63
+ || typeof entry.name !== 'string'
64
+ || description === undefined
65
+ || typeof entry.subscribed !== 'boolean'
66
+ ) {
67
+ return { ok: false };
68
+ }
69
+ lists.push({ id: entry.id, name: entry.name, description, subscribed: entry.subscribed });
70
+ }
71
+
72
+ return { ok: true, preferences: { global_unsubscribed: payload.global_unsubscribed, lists } };
73
+ }
74
+
75
+ export function narrowCustomerSessions(payload: unknown): CustomerSessionsResult {
76
+ if (!isRecord(payload) || !Array.isArray(payload.sessions)) return { ok: false };
77
+
78
+ const sessions: CustomerSession[] = [];
79
+ for (const entry of payload.sessions) {
80
+ if (!isRecord(entry)) return { ok: false };
81
+ const ipAddress = readNullableString(entry, 'ip_address');
82
+ const userAgent = readNullableString(entry, 'user_agent');
83
+ if (
84
+ typeof entry.id !== 'string'
85
+ || typeof entry.created_at !== 'string'
86
+ || typeof entry.expires_at !== 'string'
87
+ || ipAddress === undefined
88
+ || userAgent === undefined
89
+ || typeof entry.current !== 'boolean'
90
+ ) {
91
+ return { ok: false };
92
+ }
93
+ sessions.push({
94
+ id: entry.id,
95
+ created_at: entry.created_at,
96
+ expires_at: entry.expires_at,
97
+ ip_address: ipAddress,
98
+ user_agent: userAgent,
99
+ current: entry.current,
100
+ });
101
+ }
102
+
103
+ return { ok: true, sessions };
104
+ }
105
+
106
+ /** Whether any device other than this one is signed in. */
107
+ export function hasOtherSessions(sessions: readonly CustomerSession[]): boolean {
108
+ return sessions.some((session) => !session.current);
109
+ }
110
+
111
+ /**
112
+ * A device, in words a buyer recognises.
113
+ *
114
+ * Browser and system names are product names, so they are not translated; the
115
+ * catalog owns the sentence they sit in. Order matters: Edge reports itself as
116
+ * Chrome, and Chrome reports itself as Safari, so the most specific claim has
117
+ * to be tested first or every device is a Safari.
118
+ */
119
+ export function describeUserAgent(
120
+ userAgent: string | null,
121
+ ): { browser: string; os: string | null } | null {
122
+ const value = userAgent?.trim();
123
+ if (!value) return null;
124
+
125
+ const browser = value.includes('Edg/')
126
+ ? 'Edge'
127
+ : value.includes('OPR/') || value.includes('Opera')
128
+ ? 'Opera'
129
+ : value.includes('Firefox/')
130
+ ? 'Firefox'
131
+ : value.includes('Chrome/')
132
+ ? 'Chrome'
133
+ : value.includes('Safari/')
134
+ ? 'Safari'
135
+ : null;
136
+
137
+ if (!browser) return null;
138
+
139
+ const os = value.includes('Windows')
140
+ ? 'Windows'
141
+ : value.includes('Android')
142
+ ? 'Android'
143
+ : value.includes('iPhone') || value.includes('iPad')
144
+ ? 'iOS'
145
+ : value.includes('Mac OS X') || value.includes('Macintosh')
146
+ ? 'macOS'
147
+ : value.includes('Linux')
148
+ ? 'Linux'
149
+ : null;
150
+
151
+ return { browser, os };
152
+ }
153
+
154
+ /**
155
+ * The two letters shown while a buyer has no photo.
156
+ *
157
+ * The email is the fallback because it is the one thing every account has —
158
+ * `ensurePortalIdentity` fills the name from it when the buyer never set one.
159
+ */
160
+ export function profileInitials(name: string, email: string): string {
161
+ const source = name.trim() || email.split('@')[0]?.trim() || '';
162
+ const words = source.split(/\s+/).filter(Boolean);
163
+ if (words.length === 0) return '?';
164
+ if (words.length === 1) return (words[0] ?? '').slice(0, 2).toLocaleUpperCase();
165
+ const first = (words[0] ?? '').charAt(0);
166
+ const last = (words[words.length - 1] ?? '').charAt(0);
167
+ return `${first}${last}`.toLocaleUpperCase();
168
+ }
169
+
170
+ /** A name the producer will accept: `CustomerProfileUpdateBodySchema` requires one. */
171
+ export function isSaveableName(value: string): boolean {
172
+ return value.trim().length >= 2;
173
+ }
174
+
175
+ /** The producer refuses anything larger, after buffering it. Stop it here. */
176
+ export const MAX_AVATAR_BYTES = 10 * 1024 * 1024;
177
+
178
+ export function isAcceptableAvatarSize(bytes: number): boolean {
179
+ return bytes > 0 && bytes <= MAX_AVATAR_BYTES;
180
+ }
181
+
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The OTP email's sign-in link, handed to the account page in a cookie
3
+ * (ADR-0064 S11).
4
+ *
5
+ * **Following the link verifies nothing.** It carries the email and the code
6
+ * the buyer was just sent, and everything here does with them is put them in
7
+ * the form. No request is made, nothing is consumed, and no session is created
8
+ * — a corporate mail scanner that fetches the URL leaves the code exactly as
9
+ * valid as it found it, which is the property ADR-0052 chose codes to protect
10
+ * and the reason this link is allowed to exist beside them.
11
+ *
12
+ * The link's `sx_login_email` / `sx_login_code` parameters never reach the
13
+ * browser. The Shoppex edge worker injects merchant analytics — Google
14
+ * Analytics, GTM, Meta Pixel, TikTok — synchronously into `<head>`, and those
15
+ * run before this storefront's bundle does, so a page-view beacon would carry a
16
+ * live one-time code to a third party whatever this page did with the URL a
17
+ * moment later. The worker therefore answers the link with a redirect to the
18
+ * clean URL and puts the pair in the cookie named below. That worker is the
19
+ * only producer: there is no query-string path here, because a URL carrying
20
+ * these values is one the browser is never supposed to see.
21
+ *
22
+ * The cookie is deliberately readable by JavaScript — this page has to read it
23
+ * — which is why the worker gives it two minutes and why the read below deletes
24
+ * it on the way out. It holds a credential for exactly one navigation.
25
+ *
26
+ * The document lane reads the same cookie in
27
+ * `packages/storefront-commerce/src/sx/account-signin-link.ts`. This template is
28
+ * built standalone against the published SDK and cannot import from the
29
+ * monorepo, so it carries its own copy and pins the name and value shape in
30
+ * `tests/account-signin-link.test.ts` — the same arrangement the document lane
31
+ * uses for the same reason.
32
+ *
33
+ * Neither value is judged beyond "is it usable as a form value". The backend is
34
+ * the authority on whether a code is valid, and a client that decided the shape
35
+ * of a code would be inventing a contract the producer never promised.
36
+ */
37
+
38
+ /** The worker's cookie name, pinned here because it cannot be imported. */
39
+ export const ACCOUNT_SIGN_IN_PREFILL_COOKIE = '__Host-sx_signin_prefill';
40
+
41
+ const MAX_EMAIL_LENGTH = 254;
42
+ const MAX_CODE_LENGTH = 64;
43
+
44
+ /**
45
+ * Deleting a cookie is a write, and the `__Host-` prefix makes the browser
46
+ * REJECT a write that is not `Secure` with `Path=/` and no `Domain`. A delete
47
+ * that dropped those attributes would silently do nothing and leave the code in
48
+ * the jar for the rest of its two minutes, so the attributes mirror the
49
+ * worker's exactly.
50
+ */
51
+ const CLEAR_PREFILL_COOKIE = `${ACCOUNT_SIGN_IN_PREFILL_COOKIE}=; Path=/; Secure; SameSite=Lax; Max-Age=0`;
52
+
53
+ export interface AccountSignInPrefill {
54
+ email: string;
55
+ code: string;
56
+ }
57
+
58
+ /** The two things this needs from `document`: read the jar, write one cookie back. */
59
+ export interface AccountCookieJar {
60
+ cookie: string;
61
+ }
62
+
63
+ /** A form value has to be one line and non-empty to be worth putting in a field. */
64
+ function usable(raw: unknown, maxLength: number): string {
65
+ const value = typeof raw === 'string' ? raw.trim() : '';
66
+ if (!value || value.length > maxLength) return '';
67
+ return /\s/.test(value) ? '' : value;
68
+ }
69
+
70
+ /** The raw, still-encoded value of one cookie in a `document.cookie` string. */
71
+ function cookieValue(jar: string, name: string): string | null {
72
+ for (const entry of jar.split(';')) {
73
+ const separator = entry.indexOf('=');
74
+ if (separator < 0) continue;
75
+ if (entry.slice(0, separator).trim() !== name) continue;
76
+ return entry.slice(separator + 1).trim();
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * The email and code to put in the login form, or null when the cookie does not
83
+ * carry a usable pair. Half a pair is not a prefill: filling one field and
84
+ * leaving the other empty is worse than leaving the form untouched.
85
+ */
86
+ function parsePrefill(raw: string): AccountSignInPrefill | null {
87
+ let value: unknown;
88
+ try {
89
+ value = JSON.parse(decodeURIComponent(raw));
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
94
+
95
+ const record = value as Record<string, unknown>;
96
+ const email = usable(record.email, MAX_EMAIL_LENGTH);
97
+ const code = usable(record.code, MAX_CODE_LENGTH);
98
+ if (!email || !code) return null;
99
+ return { email, code };
100
+ }
101
+
102
+ /**
103
+ * Read the prefill and delete the cookie, in that one step.
104
+ *
105
+ * Reading and deleting are the same operation on purpose: a caller that could
106
+ * read without deleting would leave a credential in a JavaScript-readable
107
+ * cookie for every script on the page to find, for two minutes, on a surface
108
+ * where merchants install their own analytics.
109
+ *
110
+ * The delete happens BEFORE the value is parsed, so a malformed cookie — the
111
+ * only kind this page cannot use — is removed just as surely as a good one.
112
+ * An absent cookie is the ordinary case (a buyer who typed the URL) and is
113
+ * neither a prefill nor a reason to write anything.
114
+ */
115
+ export function takeAccountSignInPrefill(jar: AccountCookieJar): AccountSignInPrefill | null {
116
+ const raw = cookieValue(jar.cookie, ACCOUNT_SIGN_IN_PREFILL_COOKIE);
117
+ if (raw === null) return null;
118
+
119
+ jar.cookie = CLEAR_PREFILL_COOKIE;
120
+ return parsePrefill(raw);
121
+ }
122
+
@@ -0,0 +1,130 @@
1
+ import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
2
+
3
+ /**
4
+ * The subscriptions panel's pure half: which date matters, how the plan bills,
5
+ * what cancelling actually does, and the shape of the billing history.
6
+ */
7
+
8
+ /**
9
+ * The one date worth showing, and what it means.
10
+ *
11
+ * Order matters and is not cosmetic. A cancelled subscription that still has a
12
+ * period end would otherwise read "Renews 1 Apr" — the opposite of the truth —
13
+ * and a subscription already set to stop must say so rather than advertise the
14
+ * renewal it will never take.
15
+ */
16
+ export type SubscriptionTiming =
17
+ | { kind: 'canceled'; date: string }
18
+ | { kind: 'ends'; date: string }
19
+ | { kind: 'trial'; date: string }
20
+ | { kind: 'renews'; date: string };
21
+
22
+ export function subscriptionTiming(subscription: SdkCustomerSubscription): SubscriptionTiming {
23
+ if (subscription.status.toUpperCase() === 'CANCELED' && subscription.canceled_at) {
24
+ return { kind: 'canceled', date: subscription.canceled_at };
25
+ }
26
+ if (subscription.cancel_at_period_end) {
27
+ return { kind: 'ends', date: subscription.current_period_end };
28
+ }
29
+ if (subscription.status.toUpperCase() === 'TRIALING' && subscription.trial_end) {
30
+ return { kind: 'trial', date: subscription.trial_end };
31
+ }
32
+ return { kind: 'renews', date: subscription.current_period_end };
33
+ }
34
+
35
+ /** The catalog key for a timing, so the panel never spells one itself. */
36
+ export function subscriptionTimingKey(timing: SubscriptionTiming): string {
37
+ return `subscriptions.${timing.kind}`;
38
+ }
39
+
40
+ const KNOWN_INTERVALS: ReadonlySet<string> = new Set(['day', 'week', 'month', 'year']);
41
+
42
+ /**
43
+ * How often the plan bills, as a catalog key plus its parameters.
44
+ *
45
+ * Singular and plural are separate keys rather than one string with a number in
46
+ * it: "every 1 months" is wrong in English and worse in the languages that
47
+ * decline the noun. An interval the gateway spells in a way this catalog does
48
+ * not know keeps the raw word instead of being renamed into one that is close.
49
+ */
50
+ export function billingMessage(
51
+ plan: NonNullable<SdkCustomerSubscription['plan']>,
52
+ ): { key: string; params: { count: number; interval: string } } {
53
+ const interval = plan.interval.trim().toLowerCase();
54
+ // A gateway that reports 0 or a negative count still bills once per interval.
55
+ const count = plan.interval_count > 0 ? plan.interval_count : 1;
56
+ const params = { count, interval: plan.interval };
57
+
58
+ if (!KNOWN_INTERVALS.has(interval)) {
59
+ return { key: 'subscriptions.billingOther', params };
60
+ }
61
+
62
+ const suffix = count === 1 ? 'One' : 'Other';
63
+ const name = interval.charAt(0).toUpperCase() + interval.slice(1);
64
+ return { key: `subscriptions.billing${name}${suffix}`, params };
65
+ }
66
+
67
+ /**
68
+ * Whether a cancellation lets the buyer keep the time they already paid for.
69
+ *
70
+ * PayPal cannot end a billing agreement at the period boundary, so a PayPal
71
+ * cancellation takes effect at once. Every other gateway can, and does — which
72
+ * is why the confirmation copy differs: telling a PayPal buyer they keep access
73
+ * until April would be a promise this template cannot keep.
74
+ */
75
+ export function cancelsAtPeriodEnd(gateway: string): boolean {
76
+ return gateway.trim().toUpperCase() !== 'PAYPAL';
77
+ }
78
+
79
+ export interface BillingHistoryInvoice {
80
+ uniqid: string;
81
+ total: string;
82
+ currency: string;
83
+ status: string;
84
+ created_at: string;
85
+ }
86
+
87
+ export type BillingHistoryResult =
88
+ | { ok: true; invoices: BillingHistoryInvoice[] }
89
+ | { ok: false };
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ function readString(record: Record<string, unknown>, key: string): string | null {
96
+ const value = record[key];
97
+ return typeof value === 'string' ? value : null;
98
+ }
99
+
100
+ /**
101
+ * `GET /subscriptions/:uniqid/billing-history`, narrowed.
102
+ *
103
+ * The SDK hands this back as `unknown` because `@shoppex/contracts` defines no
104
+ * schema for it, so the narrowing lives here — with a test against the shape
105
+ * `SubscriptionService.getBillingHistory` really emits. A row missing any of
106
+ * these fields fails the whole read rather than rendering an invoice with a
107
+ * blank amount, which is the one thing a billing history must never do.
108
+ */
109
+ export function narrowBillingHistory(payload: unknown): BillingHistoryResult {
110
+ if (!isRecord(payload) || !Array.isArray(payload.invoices)) return { ok: false };
111
+
112
+ const invoices: BillingHistoryInvoice[] = [];
113
+ for (const entry of payload.invoices) {
114
+ if (!isRecord(entry)) return { ok: false };
115
+
116
+ const uniqid = readString(entry, 'uniqid');
117
+ const total = readString(entry, 'total');
118
+ const currency = readString(entry, 'currency');
119
+ const status = readString(entry, 'status');
120
+ const createdAt = readString(entry, 'created_at');
121
+ if (uniqid === null || total === null || currency === null || status === null || createdAt === null) {
122
+ return { ok: false };
123
+ }
124
+
125
+ invoices.push({ uniqid, total, currency, status, created_at: createdAt });
126
+ }
127
+
128
+ return { ok: true, invoices };
129
+ }
130
+
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The support panel's pure half: the ticket thread's shape, what closes a
3
+ * ticket, and what counts as a message worth sending.
4
+ */
5
+
6
+ export type SupportMessageRole = 'CUSTOMER' | 'MERCHANT' | 'SYSTEM';
7
+
8
+ export interface SupportMessage {
9
+ id: string;
10
+ /** 'CUSTOMER' | 'MERCHANT' | 'SYSTEM' as the producer stores it. */
11
+ role: SupportMessageRole;
12
+ message: string;
13
+ created_at: string;
14
+ }
15
+
16
+ export interface SupportTicket {
17
+ uniqid: string;
18
+ /** Null when the ticket was opened without one. */
19
+ title: string | null;
20
+ status: string;
21
+ /** The order this ticket is about, when the buyer named one. */
22
+ invoice_id: string | null;
23
+ created_at: string;
24
+ updated_at: string;
25
+ messages: SupportMessage[];
26
+ }
27
+
28
+ export type SupportTicketResult =
29
+ | { ok: true; ticket: SupportTicket }
30
+ | { ok: false };
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+
36
+ function readNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
37
+ const value = record[key];
38
+ if (value === null || typeof value === 'string') return value;
39
+ return undefined;
40
+ }
41
+
42
+ function isSupportMessageRole(value: unknown): value is SupportMessageRole {
43
+ return value === 'CUSTOMER' || value === 'MERCHANT' || value === 'SYSTEM';
44
+ }
45
+
46
+ /**
47
+ * `GET /tickets/:uniqid`, narrowed.
48
+ *
49
+ * The producer nests the ticket one level down (`{ ticket: { … } }`) and its
50
+ * messages come back as a flat, already-ordered array. A thread that cannot be
51
+ * read completely is not shown at all: a conversation missing a message reads
52
+ * as the merchant never answering.
53
+ */
54
+ export function narrowSupportTicket(payload: unknown): SupportTicketResult {
55
+ if (!isRecord(payload) || !isRecord(payload.ticket)) return { ok: false };
56
+
57
+ const raw = payload.ticket;
58
+ const uniqid = raw.uniqid;
59
+ const title = readNullableString(raw, 'title');
60
+ const status = raw.status;
61
+ const invoiceId = readNullableString(raw, 'invoice_id');
62
+ const createdAt = raw.created_at;
63
+ const updatedAt = raw.updated_at;
64
+
65
+ if (
66
+ typeof uniqid !== 'string'
67
+ || title === undefined
68
+ || typeof status !== 'string'
69
+ || invoiceId === undefined
70
+ || typeof createdAt !== 'string'
71
+ || typeof updatedAt !== 'string'
72
+ || !Array.isArray(raw.messages)
73
+ ) {
74
+ return { ok: false };
75
+ }
76
+
77
+ const messages: SupportMessage[] = [];
78
+ for (const entry of raw.messages) {
79
+ if (!isRecord(entry)) return { ok: false };
80
+ const { id, role, message, created_at: messageCreatedAt } = entry;
81
+ if (
82
+ typeof id !== 'string'
83
+ || !isSupportMessageRole(role)
84
+ || typeof message !== 'string'
85
+ || typeof messageCreatedAt !== 'string'
86
+ ) {
87
+ return { ok: false };
88
+ }
89
+ messages.push({ id, role, message, created_at: messageCreatedAt });
90
+ }
91
+
92
+ return {
93
+ ok: true,
94
+ ticket: {
95
+ uniqid,
96
+ title,
97
+ status,
98
+ invoice_id: invoiceId,
99
+ created_at: createdAt,
100
+ updated_at: updatedAt,
101
+ messages,
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * A closed ticket takes no replies — the producer answers 400 for one — so the
108
+ * form is replaced rather than left to fail on submit.
109
+ */
110
+ export function isTicketClosed(status: string): boolean {
111
+ return status.trim().toUpperCase() === 'CLOSED';
112
+ }
113
+
114
+ /**
115
+ * Whether there is a message here at all.
116
+ *
117
+ * Two characters, matching the hosted portal, and measured after trimming: a
118
+ * field holding three spaces is an empty ticket that costs the merchant a
119
+ * notification and the buyer an answer to nothing.
120
+ */
121
+ export function isSendableMessage(value: string): boolean {
122
+ return value.trim().length >= 2;
123
+ }
124
+
125
+ /** The title to send, or nothing at all when the buyer left it blank. */
126
+ export function ticketTitleToSend(value: string): string | undefined {
127
+ const title = value.trim();
128
+ return title.length > 0 ? title : undefined;
129
+ }
130
+
131
+ /** Who wrote a message, as a catalog key. */
132
+ export function messageAuthorKey(role: SupportMessageRole): string {
133
+ if (role === 'CUSTOMER') return 'support.fromYou';
134
+ if (role === 'SYSTEM') return 'support.fromSystem';
135
+ return 'support.fromMerchant';
136
+ }
137
+
138
+ /**
139
+ * The id of a ticket that was just created, or null when the response did not
140
+ * carry one.
141
+ *
142
+ * `POST /tickets` answers `{ uniqid }` and the panel opens that thread next.
143
+ * Without an id there is nothing to open, and guessing one would send the buyer
144
+ * to somebody else's ticket or to a 404 dressed as their own.
145
+ */
146
+ export function readCreatedTicketUniqid(data: unknown): string | null {
147
+ if (!isRecord(data)) return null;
148
+ const uniqid = data.uniqid;
149
+ return typeof uniqid === 'string' && uniqid.length > 0 ? uniqid : null;
150
+ }
151
+
152
+ /** Whether this message sits on the buyer's side of the thread. */
153
+ export function isOwnMessage(role: SupportMessageRole): boolean {
154
+ return role === 'CUSTOMER';
155
+ }