@wix/himalaya-cli 0.1610.0 → 0.1612.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,18 @@
1
1
  // Public Loyalty offering plus the current member's separately verified balance.
2
+ // The member card also carries the one-app `loyalty_status_section` lines (wix-loyalty-mobile
3
+ // StatusSection): tier, points expiry and "Rewards available (N)". They are private like the
4
+ // balance, read only beside it, and each one is blank rather than guessed when its read fails.
2
5
  import { defineService } from "../_shared/collection-service.js";
3
6
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
4
7
  import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
5
- import { formatCurrency, formatNumber } from "../_shared/engine-intl.js";
6
- import { isSingular } from "../_shared/app-locale.js";
8
+ import { calendarDay, formatCurrency, formatDateMedium, formatNumber } from "../_shared/engine-intl.js";
9
+ import { appLocale, isSingular } from "../_shared/app-locale.js";
7
10
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
8
- import { getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, type WixLoyaltyReward, type WixLoyaltyEarningRule } from "@himalaya/wix-loyalty";
11
+ import {
12
+ getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, getLoyaltyTiersProgram, listMyLoyaltyCoupons,
13
+ type WixLoyaltyReward, type WixLoyaltyEarningRule, type WixLoyaltyProgram, type WixLoyaltyAccount, type WixLoyaltyTiersProgram,
14
+ type WixLoyaltyCoupon, type WixLoyaltyRewardConfig,
15
+ } from "@himalaya/wix-loyalty";
9
16
 
10
17
  interface Row { id: string; name: string; subtitle: string }
11
18
  interface S extends LoadFlags {
@@ -15,8 +22,18 @@ interface S extends LoadFlags {
15
22
  rewardError: string; earningError: string; hideRewardError: boolean; hideEarningError: boolean; hideRewardRows: boolean;
16
23
  memberPoints: string; memberMessage: string;
17
24
  hideMemberCard: boolean; hideMemberSignIn: boolean; hideMemberRetry: boolean;
25
+ /** "Gold tier"; "" while the tiers program is off or its read failed. */
26
+ memberTier: string;
27
+ /** The expiry sentence; urgent (≤ 7 days) goes in its own field so it can be painted danger. */
28
+ memberExpiry: string; memberExpiryUrgent: string;
29
+ /** "Rewards available (N)"; hidden when N is 0 or cannot be counted honestly. */
30
+ memberRewards: string; hideMemberRewards: boolean;
18
31
  }
19
- const PRIVATE_EMPTY = { memberPoints: "", memberMessage: "", hideMemberCard: true, hideMemberSignIn: true, hideMemberRetry: true };
32
+ const PRIVATE_EMPTY = {
33
+ memberPoints: "", memberMessage: "", hideMemberCard: true, hideMemberSignIn: true, hideMemberRetry: true,
34
+ memberTier: "", memberExpiry: "", memberExpiryUrgent: "", memberRewards: "", hideMemberRewards: true,
35
+ };
36
+ const PRIVATE_KEYS = Object.keys(PRIVATE_EMPTY) as (keyof typeof PRIVATE_EMPTY)[];
20
37
  const INITIAL: S = { ...LOADING, ...PRIVATE_EMPTY, programName: "", unit: "", currency: "", rewards: [], earningRules: [], emptyText: "", earningEmptyText: "", hideCatalog: false, hideEarningHeading: true, inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: false };
21
38
 
22
39
  function amount(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
@@ -56,6 +73,72 @@ function currencyFrom(s: S, arg: unknown): string {
56
73
  const raw = (arg as { currency?: unknown }).currency;
57
74
  return typeof raw === "string" && /^[A-Z]{3}$/.test(raw) ? raw : "";
58
75
  }
76
+ /** What `reward` costs a member on `tierId` (undefined = the base tier): its row for that tier,
77
+ * else the base row, else the legacy flat `requiredPoints`. Undefined when nothing prices it. */
78
+ function memberCost(reward: WixLoyaltyReward, tierId: string | undefined): number | undefined {
79
+ const configs: WixLoyaltyRewardConfig[] | undefined = reward.discountAmount?.configsByTier ?? reward.couponReward?.fixedAmount?.configsByTier
80
+ ?? reward.couponReward?.percentage?.configsByTier ?? reward.couponReward?.freeShipping?.configsByTier;
81
+ if (!configs?.length) return amount(reward.requiredPoints) ? reward.requiredPoints : undefined;
82
+ const row = (tierId ? configs.find(config => config.tierId === tierId) : undefined) ?? baseConfig(configs);
83
+ return amount(row?.costInPoints) ? row.costInPoints : undefined;
84
+ }
85
+ /** The one-app `calculateAvailableRewardsToClaim`: active rewards the balance covers on the
86
+ * member's tier (an SPI discount prices itself in text, not points, so it is left out), plus
87
+ * claimed coupons that are still ACTIVE and not deleted. */
88
+ export function countAvailableRewards(rewards: WixLoyaltyReward[], coupons: WixLoyaltyCoupon[], balance: number, tierId: string | undefined): number {
89
+ const claimable = rewards.filter(reward => {
90
+ if (reward.active !== true || reward.type === "SPI_DISCOUNT_AMOUNT") return false;
91
+ const cost = memberCost(reward, tierId);
92
+ return cost !== undefined && cost <= balance;
93
+ }).length;
94
+ return claimable + coupons.filter(coupon => coupon.status === "ACTIVE" && !coupon.couponReference?.deleted).length;
95
+ }
96
+ /** The member's tier name; "" when the tiers program is not ACTIVE or the tier is unknown. No
97
+ * `tier.id` means the base tier, whose name lives in the program settings. */
98
+ export function currentTierName(tiers: WixLoyaltyTiersProgram | undefined, tierId: string | undefined): string {
99
+ if (tiers?.programSettings?.status !== "ACTIVE") return "";
100
+ if (!tierId) return (tiers.programSettings.baseTierDefinition?.name ?? "").trim();
101
+ return (tiers.tiers?.find(tier => tier.id === tierId)?.tierDefinition?.name ?? "").trim();
102
+ }
103
+ const DAY_MS = 86_400_000;
104
+ /** Nothing further out than this is shown (one-app `estimatePointsExpirationDuration`). */
105
+ const EXPIRY_HORIZON_DAYS = 365;
106
+ /** A date beyond this many days, a day count within it. */
107
+ const EXPIRY_DATE_AFTER_DAYS = 30;
108
+ /** Danger at or under this many days. The upstream card paints 10+ days red, which reads inverted. */
109
+ const EXPIRY_URGENT_DAYS = 7;
110
+ /** The expiry sentence and whether it is urgent; `["", false]` when nothing is shown. Counts
111
+ * CALENDAR days on the device: at 22:00, points lapsing at 18:00 tomorrow expire tomorrow. */
112
+ export function expirySentence(program: WixLoyaltyProgram | undefined, account: WixLoyaltyAccount | undefined, now: number = Date.now()): [text: string, urgent: boolean] {
113
+ const policy = program?.pointsExpiration;
114
+ // An explicit status wins: DISABLED with a leftover percentage is still disabled.
115
+ const enabled = policy?.status ? policy.status === "ENABLED" : amount(policy?.expiringPointsPercentage) && policy.expiringPointsPercentage > 0;
116
+ const at = Date.parse(account?.pointsExpiration?.expirationDate ?? "");
117
+ if (!enabled || program?.premiumFeatures?.pointsExpiration === false || !Number.isFinite(at) || at <= now) return ["", false];
118
+ const utc = (day: string) => { const [y, m, d] = day.split("-").map(Number); return Date.UTC(y, m - 1, d); };
119
+ const days = Math.round((utc(calendarDay(new Date(at))) - utc(calendarDay(new Date(now)))) / DAY_MS);
120
+ if (days > EXPIRY_HORIZON_DAYS) return ["", false];
121
+ if (days > EXPIRY_DATE_AFTER_DAYS) return [copyTemplate("loyalty.expireOn", "Expire on {date}", { date: formatDateMedium(new Date(at), appLocale()) }), false];
122
+ if (days <= 0) return [copyParam("loyalty.expireToday", "Expire today"), true];
123
+ if (days === 1) return [copyParam("loyalty.expireTomorrow", "Expire tomorrow"), true];
124
+ return [copyTemplate("loyalty.expireInDays", "Expire in {days} days", { days }), days <= EXPIRY_URGENT_DAYS];
125
+ }
126
+ type Settled<T> = { value?: T; failed: boolean };
127
+ const settle = <T>(read: Promise<T>): Promise<Settled<T>> => read.then(value => ({ value, failed: false }), () => ({ value: undefined, failed: true }));
128
+ /** The status lines for a member whose balance is known. A failed tiers read only drops the tier;
129
+ * a failed coupon or rewards read drops the count, because a partial total would understate it. */
130
+ function statusLines(program: WixLoyaltyProgram, account: WixLoyaltyAccount, balance: number, tiers: Settled<WixLoyaltyTiersProgram>,
131
+ coupons: Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>, rewards: WixLoyaltyReward[] | undefined) {
132
+ const tierId = account.tier?.id || undefined;
133
+ const tier = tiers.failed ? "" : currentTierName(tiers.value, tierId);
134
+ const [expiry, urgent] = expirySentence(program, account);
135
+ const count = coupons.failed || !rewards ? 0 : countAvailableRewards(rewards, coupons.value?.loyaltyCoupons ?? [], balance, tierId);
136
+ return {
137
+ memberTier: tier ? copyTemplate("loyalty.memberTier", "{tier} tier", { tier }) : "",
138
+ memberExpiry: urgent ? "" : expiry, memberExpiryUrgent: urgent ? expiry : "",
139
+ memberRewards: count > 0 ? copyTemplate("loyalty.rewardsAvailable", "Rewards available ({count})", { count }) : "", hideMemberRewards: count === 0,
140
+ };
141
+ }
59
142
  const COPY: LoadCopy<Row> = {
60
143
  prefix: "loyalty", empty: "No rewards to redeem yet.",
61
144
  error: "The rewards program couldn't be loaded.", signIn: "The rewards program couldn't be loaded.",
@@ -64,8 +147,8 @@ const COPY: LoadCopy<Row> = {
64
147
 
65
148
  const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedLoyalty", {
66
149
  state: INITIAL,
67
- notHydrated: ["memberPoints", "memberMessage", "hideMemberCard", "hideMemberSignIn", "hideMemberRetry"],
68
- notPersisted: ["memberPoints", "memberMessage", "hideMemberCard", "hideMemberSignIn", "hideMemberRetry"],
150
+ notHydrated: PRIVATE_KEYS,
151
+ notPersisted: PRIVATE_KEYS,
69
152
  actions: {
70
153
  prepare: s => ({ ...s, ...PRIVATE_EMPTY }),
71
154
  load: async (s, arg) => {
@@ -84,10 +167,14 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
84
167
  // Balance failures cannot discard a public offering that loaded correctly.
85
168
  const privateStateSupported = !!member.scope && ((globalThis as { himalaya?: { readLifecycleVersion?: number } }).himalaya?.readLifecycleVersion ?? 0) >= 1;
86
169
  const accountRead = member.email && privateStateSupported
87
- ? getCurrentMemberLoyaltyAccount().then(value => ({ value, failed: false })).catch(() => ({ value: undefined, failed: true }))
170
+ ? settle(getCurrentMemberLoyaltyAccount())
88
171
  : Promise.resolve({ value: undefined, failed: false });
89
- const [[rewardResult, earningResult], account] = await Promise.all([
90
- Promise.allSettled([listLoyaltyRewards(), listLoyaltyEarningRules()]), accountRead,
172
+ // The status lines' reads go only where the balance read goes: never for a visitor or an older host.
173
+ const statusRead = member.email && privateStateSupported
174
+ ? Promise.all([settle(getLoyaltyTiersProgram()), settle(listMyLoyaltyCoupons())])
175
+ : Promise.resolve([{ failed: true }, { failed: true }] as [Settled<WixLoyaltyTiersProgram>, Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>]);
176
+ const [[rewardResult, earningResult], account, [tiers, coupons]] = await Promise.all([
177
+ Promise.allSettled([listLoyaltyRewards(), listLoyaltyEarningRules()]), accountRead, statusRead,
91
178
  ]);
92
179
  const rewardResponse = rewardResult.status === "fulfilled" ? rewardResult.value : undefined;
93
180
  const earningResponse = earningResult.status === "fulfilled" ? earningResult.value : undefined;
@@ -104,7 +191,10 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
104
191
  try {
105
192
  await assertMemberAccess(member);
106
193
  const balance = account.value?.account?.points?.balance;
194
+ const known = typeof balance === "number" && Number.isFinite(balance);
107
195
  privateState = { ...PRIVATE_EMPTY, hideMemberCard: false,
196
+ ...(known && privateStateSupported && !account.failed
197
+ ? statusLines(program, account.value?.account ?? {}, balance, tiers, coupons, rewardResult.status === "fulfilled" ? (rewardResponse?.rewards ?? []) : undefined) : {}),
108
198
  memberPoints: typeof balance === "number" && Number.isFinite(balance) ? points(balance, customUnit) : "",
109
199
  memberMessage: !privateStateSupported ? copyParam("loyalty.updateApp", "Update the app to see your points securely.") : account.failed ? copyParam("loyalty.pointsError", "Your points couldn't be loaded.")
110
200
  : typeof balance !== "number" || !Number.isFinite(balance) ? copyParam("loyalty.pointsUnavailable", "Your points aren't available yet.") : "",