@wix/himalaya-cli 0.1632.0 → 0.1633.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 (39) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +4 -14
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-wire.d.ts +2 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +1 -2
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +26 -13
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-core.d.ts +0 -2
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/checkout-source.d.ts +3 -10
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +0 -2
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-order-backend.d.ts +0 -3
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/stores.d.ts +2 -2
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +2 -2
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/loyalty/src/index.d.ts +64 -0
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +2 -0
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +13 -45
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +2 -4
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +7 -11
  16. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-loyalty.mjs +39 -0
  17. package/dist/cli.mjs +127 -62
  18. package/dist/preview-assets.generated.bin +0 -0
  19. package/fixtures/wix-canned.json +3 -0
  20. package/guides/getting-started.md +2 -1
  21. package/guides/himi-json-output.md +6 -0
  22. package/guides/himi-skill.md +2 -1
  23. package/guides/iap-screen-adoption.md +4 -4
  24. package/guides/importable-sdks.md +1 -1
  25. package/guides/one-app-section-mappings.md +3 -3
  26. package/guides/sdk-reference.json +8829 -4386
  27. package/guides/skill.md +11 -1
  28. package/package.json +2 -2
  29. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +1 -1
  30. package/templates/branded-lite/dev/index.ts +2924 -2110
  31. package/templates/branded-lite/l10n/en.json +5 -3
  32. package/templates/branded-lite/tier5-src/_shared/loyalty-offerings.ts +73 -0
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/earning.ts +148 -0
  34. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +57 -31
  35. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty-rewards/index.ts +208 -0
  36. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +1 -1
  37. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +1 -1
  38. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +1 -1
  39. package/templates/retail-store/tier5-src/himi-store-product/index.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$source": "en",
3
- "$generated": "2026-11-08T10:51:06.119Z",
3
+ "$generated": "2026-11-09T18:25:39.914Z",
4
4
  "messages": {
5
5
  "h_042c862e4467": {
6
6
  "source": "Try again",
@@ -31,6 +31,7 @@
31
31
  "himi_coupons#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
32
32
  "himi_notifications#payload.body.props.children[6].body.props.children[8].body.props.children[2].body.props.title",
33
33
  "himi_loyalty#payload.body.props.children[5].body.props.children[1].body.props.title",
34
+ "himi_loyalty_reward#payload.body.props.children[3].body.props.children[1].body.props.title",
34
35
  "himi_reviews#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
35
36
  "himi_portfolio#payload.body.props.children[2].body.props.children[8].body.props.children[2].body.props.title",
36
37
  "himi_portfolio_project#payload.body.props.children[1].body.props.children[1].body.props.title",
@@ -50,6 +51,7 @@
50
51
  "himi_donations#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
51
52
  "himi_gift_cards#payload.body.props.children[4].body.props.children[8].body.props.children[2].body.props.title",
52
53
  "himi_videos#payload.body.props.children[7].body.props.children[8].body.props.children[2].body.props.title",
54
+ "himi_video#payload.body.props.children[7].body.props.title",
53
55
  "himi_podcasts#payload.body.props.children[2].body.props.children[8].body.props.children[2].body.props.title",
54
56
  "himi_forms#payload.body.props.children[5].body.props.ctaLabel",
55
57
  "himi_form#payload.body.props.children[6].body.props.ctaLabel",
@@ -221,7 +223,6 @@
221
223
  "himi_member#payload.body.props.children[3].body.props.children[0].body.props.text",
222
224
  "himi_chat_thread#payload.body.props.children[2].body.props.children[0].body.props.text",
223
225
  "himi_coupon#payload.body.props.children[1].body.props.children[0].body.props.text",
224
- "himi_loyalty_reward#payload.body.props.children[1].body.props.children[0].body.props.text",
225
226
  "himi_review#payload.body.props.children[1].body.props.children[0].body.props.text",
226
227
  "himi_invoice#payload.body.props.children[1].body.props.children[0].body.props.text",
227
228
  "himi_badge#payload.body.props.children[1].body.props.children[0].body.props.text",
@@ -413,6 +414,7 @@
413
414
  "himi_coupons#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
414
415
  "himi_notifications#payload.body.props.children[6].body.props.children[8].body.props.children[3].body.props.title",
415
416
  "himi_loyalty#payload.body.props.children[7].body.props.children[1].body.props.children[3].body.props.title",
417
+ "himi_loyalty_reward#payload.body.props.children[2].body.props.children[3].body.props.title",
416
418
  "himi_reviews#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
417
419
  "himi_portfolio#payload.body.props.children[2].body.props.children[8].body.props.children[3].body.props.title",
418
420
  "himi_reservations#payload.body.props.children[6].body.props.children[2].body.props.title",
@@ -427,7 +429,7 @@
427
429
  "himi_gift_cards#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
428
430
  "himi_videos#payload.body.props.children[7].body.props.children[8].body.props.children[3].body.props.title",
429
431
  "himi_video_channel#payload.body.props.children[8].body.props.title",
430
- "himi_video#payload.body.props.children[9].body.props.title",
432
+ "himi_video#payload.body.props.children[13].body.props.title",
431
433
  "himi_podcasts#payload.body.props.children[2].body.props.children[8].body.props.children[3].body.props.title",
432
434
  "himi_forms#payload.body.props.children[7].body.props.ctaLabel",
433
435
  "himi_galleries#payload.body.props.children[4].body.props.children[8].body.props.children[3].body.props.title",
@@ -0,0 +1,73 @@
1
+ // Raw REST tier configurations are intentionally matched exactly: a tier without
2
+ // a configuration does not inherit an offering from a different tier.
3
+ import type { WixLoyaltyReward, WixLoyaltyRewardConfig, WixLoyaltyEarningRule, WixLoyaltyTiersProgram } from "@himalaya/wix-loyalty";
4
+ import { copyParam, copyTemplate } from "./himi-copy.js";
5
+ import { isSingular } from "./app-locale.js";
6
+ import { formatCurrency, formatNumber } from "./engine-intl.js";
7
+
8
+ export interface OfferingRow { id: string; name: string; subtitle: string }
9
+ export interface OfferingGroup {
10
+ id: string; name: string; description: string; threshold: string;
11
+ earningRules: OfferingRow[]; rewards: OfferingRow[];
12
+ hideEarning: boolean; hideRewards: boolean; hideTitle: boolean;
13
+ }
14
+ export const OFFERING_TIER_LIMIT = 3; // wix-loyalty-mobile constants/common.ts
15
+ const amount = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0;
16
+ export function pointLabel(value: number, unit: string): string {
17
+ return copyTemplate("loyalty.pointsLabel", "{points} {unit}", { points: formatNumber(value), unit: unit || copyParam(isSingular(value) ? "loyalty.pointOne" : "loyalty.pointMany", isSingular(value) ? "point" : "points") });
18
+ }
19
+ export function rewardConfigs(reward: WixLoyaltyReward): WixLoyaltyRewardConfig[] | undefined {
20
+ return reward.discountAmount?.configsByTier ?? reward.couponReward?.fixedAmount?.configsByTier
21
+ ?? reward.couponReward?.percentage?.configsByTier ?? reward.couponReward?.freeShipping?.configsByTier;
22
+ }
23
+ /** Current-member pricing never inherits a different tier's configuration. */
24
+ export function memberRewardCost(reward: WixLoyaltyReward, tierId?: string): number | undefined {
25
+ const configs = rewardConfigs(reward);
26
+ if (!configs?.length) return amount(reward.requiredPoints) ? reward.requiredPoints : undefined;
27
+ const config = configFor(configs, tierId);
28
+ return amount(config?.costInPoints) ? config.costInPoints : undefined;
29
+ }
30
+ function configFor<T extends { tierId?: string }>(configs: T[] | undefined, tierId?: string): T | undefined {
31
+ const rows = configs?.filter(c => (c.tierId || undefined) === tierId);
32
+ return rows?.length === 1 ? rows[0] : undefined;
33
+ }
34
+ export function offeringRewardLabel(reward: WixLoyaltyReward, tierId: string | undefined, unit: string, currency: string): string {
35
+ if (reward.type === "SPI_DISCOUNT_AMOUNT") return reward.spiDiscountAmount?.description || copyParam("loyalty.checkoutReward", "Use this reward at checkout.");
36
+ const config = configFor(rewardConfigs(reward), tierId);
37
+ const cost = rewardConfigs(reward)?.length ? config?.costInPoints : reward.requiredPoints;
38
+ const price = !amount(cost) ? copyParam("loyalty.costUnavailable", "Points details unavailable")
39
+ : cost === 0 ? copyParam("loyalty.free", "Free") : pointLabel(cost, unit);
40
+ let benefit = "";
41
+ const money = Number(config?.amount);
42
+ if (config?.amount !== undefined && amount(money) && currency) benefit = formatCurrency(money, currency);
43
+ else if (amount(config?.percentage)) benefit = copyTemplate("loyalty.percentOff", "{percent}% off", { percent: formatNumber(config.percentage) });
44
+ else if (reward.couponReward?.freeShipping) benefit = copyParam("loyalty.freeShipping", "Free shipping");
45
+ return benefit ? copyTemplate("loyalty.benefitCost", "{benefit} · {cost}", { benefit, cost: price }) : price;
46
+ }
47
+ function earningLabel(rule: WixLoyaltyEarningRule, tierId: string | undefined, unit: string, currency: string): string {
48
+ if (rule.fixedAmount) {
49
+ const value = configFor(rule.fixedAmount.configs, tierId)?.points;
50
+ return amount(value) ? pointLabel(value, unit) : copyParam("loyalty.earningUnavailable", "Points details unavailable");
51
+ }
52
+ const rate = configFor(rule.conversionRate?.configs, tierId);
53
+ if (!rate || !amount(rate.points) || !amount(rate.moneyAmount) || rate.moneyAmount === 0 || !currency) return copyParam("loyalty.earningUnavailable", "Points details unavailable");
54
+ return copyTemplate("loyalty.pointsPerSpend", "{points} per {amount}", {
55
+ points: pointLabel(rate.points, unit), amount: formatCurrency(rate.moneyAmount, currency, undefined, Number.isInteger(rate.moneyAmount) ? 0 : undefined),
56
+ });
57
+ }
58
+ export function buildOfferings(program: WixLoyaltyTiersProgram | undefined, rewards: WixLoyaltyReward[], rules: WixLoyaltyEarningRule[], unit: string, currency: string): OfferingGroup[] {
59
+ const active = program?.programSettings?.status === "ACTIVE";
60
+ const tiers = [{ id: undefined as string | undefined, tierDefinition: program?.programSettings?.baseTierDefinition, requiredPoints: undefined as number | undefined }, ...(active ? (program?.tiers ?? []).filter(t => !!t.id) : [])];
61
+ return tiers.map(tier => {
62
+ const earningRules = rules.filter(rule => rule.status === "ACTIVE" && !!configFor(rule.fixedAmount?.configs ?? rule.conversionRate?.configs, tier.id))
63
+ .filter(rule => !!rule.id).map(rule => ({ id: rule.id!, name: rule.title || copyParam("loyalty.earnFallback", "Earn points"), subtitle: earningLabel(rule, tier.id, unit, currency) }));
64
+ const tierRewards = rewards.filter(reward => reward.active === true && !!reward.id && (reward.type === "SPI_DISCOUNT_AMOUNT" || (rewardConfigs(reward)?.length ? !!configFor(rewardConfigs(reward), tier.id) : !tier.id)))
65
+ .map(reward => ({ id: reward.id, name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: offeringRewardLabel(reward, tier.id, unit, currency) }));
66
+ return { id: tier.id || "base", name: active ? tier.tierDefinition?.name || copyParam("loyalty.baseTier", "Base tier") : "",
67
+ description: active ? tier.tierDefinition?.description || "" : "", threshold: active && amount(tier.requiredPoints) ? copyTemplate("loyalty.tierThreshold", "From {points}", { points: pointLabel(tier.requiredPoints, unit) }) : "",
68
+ earningRules, rewards: tierRewards, hideEarning: !earningRules.length, hideRewards: !tierRewards.length, hideTitle: !active };
69
+ });
70
+ }
71
+ export function visibleOfferings(groups: OfferingGroup[], expanded: boolean): OfferingGroup[] {
72
+ return expanded ? groups : groups.slice(0, OFFERING_TIER_LIMIT);
73
+ }
@@ -0,0 +1,148 @@
1
+ import { getMyMember, updateMember } from "@himalaya/wix-members";
2
+ import { listMyLoyaltyFollowedChannels, followMyLoyaltyChannel, type WixLoyaltyProgram, type WixLoyaltyEarningRule } from "@himalaya/wix-loyalty";
3
+ import { open } from "@himalaya/open-url";
4
+ import { navigate } from "@himalaya/state";
5
+ import { memberAccess, assertMemberAccess, type MemberAccess } from "../_shared/member-access.js";
6
+ import { copyParam } from "../_shared/himi-copy.js";
7
+
8
+ export interface SocialRow { id: string; name: string; label: string; followed: boolean }
9
+ export interface EarningState {
10
+ hideBirthday: boolean; birthdayLabel: string; birthdayDraft: number; birthdayPicked: boolean; birthdayOpen: boolean;
11
+ socialChannels: SocialRow[]; earnBusy: boolean; earnMessage: string;
12
+ }
13
+ export const EARNING_EMPTY: EarningState = { hideBirthday: true, birthdayLabel: "", birthdayDraft: 0, birthdayPicked: false, birthdayOpen: false, socialChannels: [], earnBusy: false, earnMessage: "" };
14
+ const business = () => (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.__himiPreferenceBusiness;
15
+ const obj = (v: unknown): Record<string, unknown> => v && typeof v === "object" ? v as Record<string, unknown> : {};
16
+ const HOSTS: Record<string, string> = { FACEBOOK: "facebook.com", INSTAGRAM: "instagram.com", LINKEDIN: "linkedin.com", TIKTOK: "tiktok.com", X: "x.com" };
17
+ /** Engine-safe URL check; never hand a program-supplied scheme or credentials to open_url. */
18
+ export function validSocialUrl(type: string, url: string): boolean {
19
+ const host = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(url)?.[1]?.toLowerCase();
20
+ return !!host && !!HOSTS[type] && (host === HOSTS[type] || host === `www.${HOSTS[type]}` || host === `m.${HOSTS[type]}`);
21
+ }
22
+ export function birthdayRule(rule: WixLoyaltyEarningRule): boolean {
23
+ return rule.status === "ACTIVE" && rule.triggerActivityType === "segments-contact_enters_segment" && rule.metadata?.ruleTypeTag?.ruleType === "BIRTHDAY";
24
+ }
25
+ export function birthdateAt(value: number): string | undefined {
26
+ if (!Number.isFinite(value)) return undefined;
27
+ const date = new Date(value * 1000), today = new Date();
28
+ if (!Number.isFinite(date.getTime()) || date.getFullYear() < 1900 || date > today) return undefined;
29
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
30
+ }
31
+
32
+ // Private controller state never hydrates. Every network completion rechecks the
33
+ // authenticated member AND business before publishing, including background writes.
34
+ export function createEarningController<S extends EarningState>(publish: () => Promise<unknown>) {
35
+ let revision = 0, busy = false;
36
+ let context: { member: MemberAccess; site: unknown; id: string; birthday: boolean; date: string; channels: { type: string; url: string; followed: boolean }[] } | undefined;
37
+ let outcome: { version: number; message: string; date?: string } | undefined;
38
+ const check = async (c: NonNullable<typeof context>) => {
39
+ await assertMemberAccess(c.member);
40
+ if (business() !== c.site || context !== c) throw new Error("Member access changed");
41
+ };
42
+ const view = (): EarningState => ({ ...EARNING_EMPTY, earnBusy: busy, hideBirthday: !context?.birthday || (!!context.member.email && !context.id),
43
+ birthdayLabel: copyParam(context?.date ? "loyalty.editBirthday" : "loyalty.addBirthday", context?.date ? "Edit birthday" : "Add birthday"),
44
+ socialChannels: context?.channels.map(c => ({ id: c.type, name: c.type, followed: c.followed,
45
+ label: copyParam(c.followed ? "loyalty.followed" : "loyalty.follow", c.followed ? "Followed" : "Follow") })) ?? [] });
46
+ const signIn = (s: S) => navigate(s, { screenId: "himi_sign_in", args: { returnTo: "himi_loyalty" } });
47
+ async function write(c: NonNullable<typeof context>, version: number, task: () => Promise<string | undefined>) {
48
+ let message = copyParam("loyalty.earnSaved", "Saved. Your points will update when the earning rule runs."), date: string | undefined;
49
+ try { await check(c); date = await task(); await check(c); }
50
+ catch { message = copyParam("loyalty.earnFailed", "Couldn't confirm this change. Refresh before trying again."); }
51
+ finally { busy = false; }
52
+ if (context !== c || version !== revision) { await publish(); return; }
53
+ try { await check(c); outcome = { version, message, date }; await publish(); }
54
+ catch { outcome = undefined; await publish(); }
55
+ }
56
+ return {
57
+ clear() { revision++; context = undefined; outcome = undefined; },
58
+ async load(member: MemberAccess, program: WixLoyaltyProgram, rules: WixLoyaltyEarningRule[]): Promise<EarningState> {
59
+ const version = ++revision;
60
+ const birthday = rules.some(birthdayRule);
61
+ const channels = rules.some(r => r.status === "ACTIVE" && r.triggerActivityType === "wix_loyalty-social_media_channel_followed")
62
+ ? (program.socialMediaSettings?.channels ?? []).filter(c => c.enabled === true && validSocialUrl(c.type ?? "", c.url ?? ""))
63
+ .map(c => ({ type: c.type!, url: c.url!, followed: false })) : [];
64
+ const c = { member, site: business(), id: "", birthday, date: "", channels };
65
+ context = c; outcome = undefined;
66
+ if (!member.email || (!birthday && !channels.length)) return view();
67
+ try {
68
+ const [profile, followed] = await Promise.allSettled([
69
+ birthday ? getMyMember({ fieldsets: ["FULL"] }) : Promise.resolve(undefined),
70
+ channels.length ? listMyLoyaltyFollowedChannels() : Promise.resolve(undefined),
71
+ ]);
72
+ await check(c);
73
+ if (version !== revision) return { ...EARNING_EMPTY };
74
+ let failed = false;
75
+ if (birthday) {
76
+ if (profile.status === "fulfilled" && profile.value?.member?.id) {
77
+ c.id = profile.value.member.id;
78
+ c.date = profile.value.member.contact?.birthdate ?? "";
79
+ } else { c.id = ""; failed = true; }
80
+ }
81
+ if (channels.length) {
82
+ const rows = followed.status === "fulfilled" ? followed.value?.followedChannels : undefined;
83
+ if (followed.status === "fulfilled" && (rows === undefined || Array.isArray(rows))) {
84
+ const known = new Set(rows?.map(row => row.channel));
85
+ c.channels = channels.map(channel => ({ ...channel, followed: known.has(channel.type) }));
86
+ } else { c.channels = []; failed = true; }
87
+ }
88
+ return { ...view(), earnMessage: failed ? copyParam("loyalty.earnLoadError", "Your earning actions couldn't be loaded. Refresh to try again.") : "" };
89
+ } catch {
90
+ if (context !== c || version !== revision) return { ...EARNING_EMPTY };
91
+ // A failed private read must not pretend the member has never followed.
92
+ c.channels = []; c.id = "";
93
+ return { ...view(), earnMessage: copyParam("loyalty.earnLoadError", "Your earning actions couldn't be loaded. Refresh to try again.") };
94
+ }
95
+ },
96
+ async openBirthday(s: S): Promise<S> {
97
+ if (!(await memberAccess()).email) return signIn(s);
98
+ const c = context;
99
+ if (!c?.birthday || !c.id || busy) return s;
100
+ await check(c);
101
+ const parsed = c.date ? new Date(`${c.date}T12:00:00`).getTime() / 1000 : Date.now() / 1000;
102
+ return { ...s, birthdayOpen: true, birthdayPicked: false, birthdayDraft: Number.isFinite(parsed) ? parsed : 0, earnMessage: "" };
103
+ },
104
+ draftBirthday(s: S, arg: unknown): S { const value = Number(obj(arg).value); return busy || !birthdateAt(value) ? s : { ...s, birthdayDraft: value, birthdayPicked: true }; },
105
+ closeBirthday(s: S): S { return busy ? s : { ...s, birthdayOpen: false }; },
106
+ saveBirthday(s: S): S {
107
+ const c = context, date = birthdateAt(s.birthdayDraft);
108
+ if (busy || !s.birthdayOpen || !c?.id || !c.birthday) return s;
109
+ if (!date || !s.birthdayPicked) return { ...s, earnMessage: copyParam("loyalty.birthdayInvalid", "Choose a birthday in the past.") };
110
+ busy = true;
111
+ void write(c, revision, async () => {
112
+ const result = await updateMember(c.id, { member: { contact: { birthdate: date } } });
113
+ if (result.member?.contact?.birthdate !== date) throw new Error("Birthday update was not confirmed");
114
+ return date;
115
+ });
116
+ return { ...s, earnBusy: true, earnMessage: copyParam("loyalty.saving", "Saving…") };
117
+ },
118
+ async followChannel(s: S, arg: unknown): Promise<S> {
119
+ if (!(await memberAccess()).email) return signIn(s);
120
+ const c = context, channel = c?.channels.find(row => row.type === obj(arg).id);
121
+ if (!c || !channel || busy) return s;
122
+ await check(c);
123
+ if (busy) return s;
124
+ busy = true;
125
+ void write(c, revision, async () => {
126
+ if (!channel.followed) {
127
+ const result = await followMyLoyaltyChannel(channel.type);
128
+ if (result.followedChannel?.channel !== channel.type) throw new Error("Follow was not confirmed");
129
+ await check(c); channel.followed = true;
130
+ }
131
+ const opened = await open(channel.url);
132
+ if (!opened.opened) throw new Error("Could not open channel");
133
+ return undefined;
134
+ });
135
+ return { ...s, earnBusy: true, earnMessage: copyParam("loyalty.saving", "Saving…") };
136
+ },
137
+ async finishEarning(s: S): Promise<S> {
138
+ if (context) {
139
+ try { await check(context); }
140
+ catch { context = undefined; outcome = undefined; return { ...s, ...EARNING_EMPTY }; }
141
+ }
142
+ if (!outcome || outcome.version !== revision || !context) return { ...s, earnBusy: busy };
143
+ if (outcome.date) context.date = outcome.date;
144
+ const message = outcome.message; outcome = undefined;
145
+ return { ...s, ...view(), earnMessage: message };
146
+ },
147
+ };
148
+ }
@@ -9,13 +9,20 @@ import { calendarDay, formatCurrency, formatDateMedium, formatNumber } from "../
9
9
  import { appLocale, isSingular } from "../_shared/app-locale.js";
10
10
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
11
11
  import {
12
- getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, getLoyaltyTiersProgram, listMyLoyaltyCoupons,
12
+ getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, getLoyaltyTiersProgram, listMyLoyaltyCoupons, listMyReferralRewards,
13
13
  type WixLoyaltyReward, type WixLoyaltyEarningRule, type WixLoyaltyProgram, type WixLoyaltyAccount, type WixLoyaltyTiersProgram,
14
- type WixLoyaltyCoupon, type WixLoyaltyRewardConfig,
14
+ type WixLoyaltyCoupon, type WixReferralReward,
15
15
  } from "@himalaya/wix-loyalty";
16
16
 
17
+ import { buildOfferings, visibleOfferings, offeringRewardLabel, memberRewardCost, OFFERING_TIER_LIMIT, type OfferingGroup } from "../_shared/loyalty-offerings.js";
18
+
19
+ import { createEarningController, EARNING_EMPTY, type EarningState } from "./earning.js";
20
+ import { withOnlineWrites } from "../_shared/online-actions.js";
21
+
17
22
  interface Row { id: string; name: string; subtitle: string }
18
- interface S extends LoadFlags {
23
+ interface S extends LoadFlags, EarningState {
24
+ offeringGroups: OfferingGroup[]; offeringsExpanded: boolean;
25
+ hideTierOfferings: boolean; hideBaseOffering: boolean; hideShowAllTiers: boolean; tierError: string;
19
26
  programName: string; unit: string; currency: string;
20
27
  rewards: Row[]; earningRules: Row[]; emptyText: string; earningEmptyText: string;
21
28
  hideCatalog: boolean; hideEarningHeading: boolean; inactiveText: string;
@@ -29,12 +36,13 @@ interface S extends LoadFlags {
29
36
  /** "Rewards available (N)"; hidden when N is 0 or cannot be counted honestly. */
30
37
  memberRewards: string; hideMemberRewards: boolean;
31
38
  }
32
- const PRIVATE_EMPTY = {
39
+ const PRIVATE_EMPTY = { ...EARNING_EMPTY,
33
40
  memberPoints: "", memberMessage: "", hideMemberCard: true, hideMemberSignIn: true, hideMemberRetry: true,
34
41
  memberTier: "", memberExpiry: "", memberExpiryUrgent: "", memberRewards: "", hideMemberRewards: true,
35
42
  };
36
43
  const PRIVATE_KEYS = Object.keys(PRIVATE_EMPTY) as (keyof typeof PRIVATE_EMPTY)[];
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 };
44
+ const OFFERING_EMPTY = { offeringGroups: [] as OfferingGroup[], offeringsExpanded: false, hideTierOfferings: true, hideBaseOffering: false, hideShowAllTiers: true, tierError: "" };
45
+ const INITIAL: S = { ...OFFERING_EMPTY, ...LOADING, ...PRIVATE_EMPTY, programName: "", unit: "", currency: "", rewards: [], earningRules: [], emptyText: "", earningEmptyText: "", hideCatalog: false, hideEarningHeading: true, inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: false };
38
46
 
39
47
  function amount(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
40
48
  function baseConfig<T extends { tierId?: string }>(configs: T[] | undefined): T | undefined {
@@ -73,22 +81,13 @@ function currencyFrom(s: S, arg: unknown): string {
73
81
  const raw = (arg as { currency?: unknown }).currency;
74
82
  return typeof raw === "string" && /^[A-Z]{3}$/.test(raw) ? raw : "";
75
83
  }
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
84
  /** The one-app `calculateAvailableRewardsToClaim`: active rewards the balance covers on the
86
85
  * member's tier (an SPI discount prices itself in text, not points, so it is left out), plus
87
86
  * claimed coupons that are still ACTIVE and not deleted. */
88
87
  export function countAvailableRewards(rewards: WixLoyaltyReward[], coupons: WixLoyaltyCoupon[], balance: number, tierId: string | undefined): number {
89
88
  const claimable = rewards.filter(reward => {
90
89
  if (reward.active !== true || reward.type === "SPI_DISCOUNT_AMOUNT") return false;
91
- const cost = memberCost(reward, tierId);
90
+ const cost = memberRewardCost(reward, tierId);
92
91
  return cost !== undefined && cost <= balance;
93
92
  }).length;
94
93
  return claimable + coupons.filter(coupon => coupon.status === "ACTIVE" && !coupon.couponReference?.deleted).length;
@@ -128,11 +127,12 @@ const settle = <T>(read: Promise<T>): Promise<Settled<T>> => read.then(value =>
128
127
  /** The status lines for a member whose balance is known. A failed tiers read only drops the tier;
129
128
  * a failed coupon or rewards read drops the count, because a partial total would understate it. */
130
129
  function statusLines(program: WixLoyaltyProgram, account: WixLoyaltyAccount, balance: number, tiers: Settled<WixLoyaltyTiersProgram>,
131
- coupons: Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>, rewards: WixLoyaltyReward[] | undefined) {
130
+ coupons: Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>, referrals: Settled<{ referralRewards: WixReferralReward[] }>, rewards: WixLoyaltyReward[] | undefined) {
132
131
  const tierId = account.tier?.id || undefined;
133
132
  const tier = tiers.failed ? "" : currentTierName(tiers.value, tierId);
134
133
  const [expiry, urgent] = expirySentence(program, account);
135
- const count = coupons.failed || !rewards ? 0 : countAvailableRewards(rewards, coupons.value?.loyaltyCoupons ?? [], balance, tierId);
134
+ const referralCount = referrals.value?.referralRewards.filter(r => r.rewardType === "COUPON" && r.coupon?.status === "ACTIVE").length ?? 0;
135
+ const count = coupons.failed || referrals.failed || !rewards ? 0 : countAvailableRewards(rewards, coupons.value?.loyaltyCoupons ?? [], balance, tierId) + referralCount;
136
136
  return {
137
137
  memberTier: tier ? copyTemplate("loyalty.memberTier", "{tier} tier", { tier }) : "",
138
138
  memberExpiry: urgent ? "" : expiry, memberExpiryUrgent: urgent ? expiry : "",
@@ -145,13 +145,26 @@ const COPY: LoadCopy<Row> = {
145
145
  sample: () => [{ id: "sample-reward", name: copyParam("loyalty.sampleReward", "5% off your next order"), subtitle: points(500, "") }],
146
146
  };
147
147
 
148
- const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedLoyalty", {
148
+ let loadRevision = 0;
149
+ let allOfferings: OfferingGroup[] = [];
150
+ const earning: ReturnType<typeof createEarningController<S>> = createEarningController<S>((): Promise<unknown> => svc.finishEarning());
151
+ const register = withOnlineWrites(defineService, ["saveBirthday", "followChannel"], { messageFields: ["earnMessage"] });
152
+ const svc = register<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S>; showAllTiers: (s: S) => S;
153
+ openBirthday: (s: S) => Promise<S>; draftBirthday: (s: S, arg?: unknown) => S; closeBirthday: (s: S) => S;
154
+ saveBirthday: (s: S) => S; followChannel: (s: S, arg?: unknown) => Promise<S>; finishEarning: (s: S) => Promise<S>;
155
+ }>("himiBrandedLoyalty", {
149
156
  state: INITIAL,
150
157
  notHydrated: PRIVATE_KEYS,
151
158
  notPersisted: PRIVATE_KEYS,
152
159
  actions: {
153
- prepare: s => ({ ...s, ...PRIVATE_EMPTY }),
160
+ showAllTiers: s => allOfferings.length ? ({ ...s, offeringsExpanded: true, offeringGroups: allOfferings, hideShowAllTiers: true }) : s,
161
+ openBirthday: s => earning.openBirthday(s), draftBirthday: (s, arg) => earning.draftBirthday(s, arg),
162
+ closeBirthday: s => earning.closeBirthday(s), saveBirthday: s => earning.saveBirthday(s),
163
+ followChannel: (s, arg) => earning.followChannel(s, arg), finishEarning: s => earning.finishEarning(s),
164
+ prepare: s => { loadRevision++; allOfferings = []; earning.clear(); return { ...s, ...PRIVATE_EMPTY }; },
154
165
  load: async (s, arg) => {
166
+ const revision = ++loadRevision;
167
+ earning.clear(); allOfferings = [];
155
168
  const currency = currencyFrom(s, arg);
156
169
  const business = businessStamp();
157
170
  try {
@@ -159,8 +172,8 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
159
172
  const program = response.loyaltyProgram ?? response.program;
160
173
  if (!program || !["ACTIVE", "DRAFT", "PAUSED"].includes(program.status ?? "")) throw new Error("Loyalty program response is unavailable");
161
174
  const customUnit = (program.pointDefinition?.customName ?? "").trim();
162
- const common = { ...s, ...PRIVATE_EMPTY, currency, programName: program.name || copyParam("loyalty.programFallback", "Rewards"), unit: customUnit || copyParam("loyalty.pointMany", "points"), inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: true, hideEarningHeading: true };
163
- if (program.status !== "ACTIVE") {
175
+ const common = { ...s, ...PRIVATE_EMPTY, ...OFFERING_EMPTY, currency, programName: program.name || copyParam("loyalty.programFallback", "Rewards"), unit: customUnit || copyParam("loyalty.pointMany", "points"), inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: true, hideEarningHeading: true };
176
+ if (program.status !== "ACTIVE" || program.premiumFeatures?.loyaltyProgram === false) {
164
177
  return { ...common, ...loaded("rewards", [], COPY), earningRules: [], earningEmptyText: "", hideCatalog: true,
165
178
  inactiveText: copyParam("loyalty.inactive", "The rewards program isn't available right now.") };
166
179
  }
@@ -169,23 +182,36 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
169
182
  const accountRead = member.email && privateStateSupported
170
183
  ? settle(getCurrentMemberLoyaltyAccount())
171
184
  : Promise.resolve({ value: undefined, failed: false });
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,
185
+ // Public tier metadata is shared by the visitor offering and the member status.
186
+ // Coupons and account details remain member-only.
187
+ const tiersRead = settle(getLoyaltyTiersProgram());
188
+ const couponsRead = member.email && privateStateSupported ? settle(listMyLoyaltyCoupons())
189
+ : Promise.resolve({ failed: true } as Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>);
190
+ const referralsRead = member.email && privateStateSupported ? settle(listMyReferralRewards())
191
+ : Promise.resolve({ failed: true } as Settled<{ referralRewards: WixReferralReward[] }>);
192
+ const [[rewardResult, earningResult], account, tiers, coupons, referrals] = await Promise.all([
193
+ Promise.allSettled([listLoyaltyRewards(), listLoyaltyEarningRules()]), accountRead, tiersRead, couponsRead, referralsRead,
178
194
  ]);
179
195
  const rewardResponse = rewardResult.status === "fulfilled" ? rewardResult.value : undefined;
180
196
  const earningResponse = earningResult.status === "fulfilled" ? earningResult.value : undefined;
181
197
  if (businessStamp() !== business) throw new Error("Business changed while Loyalty loaded");
182
198
  if (rewardResult.status === "rejected" && earningResult.status === "rejected") throw new Error("Loyalty catalog is unavailable");
183
199
  const rewards = (rewardResponse?.rewards ?? []).filter(reward => reward.active === true).map((reward, i) => ({
184
- id: reward.id || `reward-${i}`, name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: rewardLabel(reward, customUnit),
200
+ id: reward.id || `reward-${i}`, name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: reward.type === "SPI_DISCOUNT_AMOUNT" ? offeringRewardLabel(reward, undefined, customUnit, currency) : rewardLabel(reward, customUnit),
185
201
  }));
186
202
  const earningRules = (earningResponse?.earningRules ?? []).filter(rule => rule.status === "ACTIVE").map((rule, i) => ({
187
203
  id: rule.id || `earning-${i}`, name: rule.title || copyParam("loyalty.earnFallback", "Earn points"), subtitle: earningLabel(rule, customUnit, currency),
188
204
  }));
205
+ const tiered = !tiers.failed && tiers.value?.programSettings?.status === "ACTIVE" && program.premiumFeatures?.tiers !== false;
206
+ const allOfferingGroups = tiered ? buildOfferings(tiers.value, rewardResponse?.rewards ?? [], earningResponse?.earningRules ?? [], customUnit, currency) : [];
207
+ const offeringsExpanded = s.offeringsExpanded;
208
+ const offeringState = { offeringGroups: visibleOfferings(allOfferingGroups, offeringsExpanded), offeringsExpanded,
209
+ hideTierOfferings: !tiered, hideBaseOffering: tiered, hideShowAllTiers: !tiered || offeringsExpanded || allOfferingGroups.length <= OFFERING_TIER_LIMIT,
210
+ tierError: tiers.failed ? copyParam("loyalty.tiersError", "Tier benefits couldn't be loaded. Showing the base offering.") : "" };
211
+ if (revision !== loadRevision) return s;
212
+ allOfferings = allOfferingGroups;
213
+ const earningState = member.email && !privateStateSupported ? { ...EARNING_EMPTY } : await earning.load(member, program, earningResponse?.earningRules ?? []);
214
+ if (businessStamp() !== business) throw new Error("Business changed while Loyalty loaded");
189
215
  let privateState = { ...PRIVATE_EMPTY };
190
216
  if (member.email) {
191
217
  try {
@@ -194,7 +220,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
194
220
  const known = typeof balance === "number" && Number.isFinite(balance);
195
221
  privateState = { ...PRIVATE_EMPTY, hideMemberCard: false,
196
222
  ...(known && privateStateSupported && !account.failed
197
- ? statusLines(program, account.value?.account ?? {}, balance, tiers, coupons, rewardResult.status === "fulfilled" ? (rewardResponse?.rewards ?? []) : undefined) : {}),
223
+ ? statusLines(program, account.value?.account ?? {}, balance, tiers, coupons, referrals, rewardResult.status === "fulfilled" ? (rewardResponse?.rewards ?? []) : undefined) : {}),
198
224
  memberPoints: typeof balance === "number" && Number.isFinite(balance) ? points(balance, customUnit) : "",
199
225
  memberMessage: !privateStateSupported ? copyParam("loyalty.updateApp", "Update the app to see your points securely.") : account.failed ? copyParam("loyalty.pointsError", "Your points couldn't be loaded.")
200
226
  : typeof balance !== "number" || !Number.isFinite(balance) ? copyParam("loyalty.pointsUnavailable", "Your points aren't available yet.") : "",
@@ -207,7 +233,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
207
233
  } else {
208
234
  privateState.hideMemberSignIn = false;
209
235
  }
210
- return { ...common, ...loaded("rewards", rewards, COPY), ...privateState, earningRules, hideCatalog: false, hideEarningHeading: false,
236
+ return { ...common, ...loaded("rewards", rewards, COPY), ...privateState, ...offeringState, ...earningState, earningRules, hideCatalog: false, hideEarningHeading: false,
211
237
  rewardError: rewardResult.status === "rejected" ? copyParam("loyalty.rewardsError", "Rewards couldn't be loaded.") : "",
212
238
  earningError: earningResult.status === "rejected" ? copyParam("loyalty.earningError", "Ways to earn points couldn't be loaded.") : "",
213
239
  hideRewardError: rewardResult.status === "fulfilled", hideEarningError: earningResult.status === "fulfilled", hideRewardRows: rewardResult.status === "rejected",
@@ -215,7 +241,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
215
241
  earningEmptyText: earningResult.status === "rejected" || earningRules.length ? "" : copyParam("loyalty.earningEmpty", "No ways to earn points yet.") };
216
242
  } catch (error) {
217
243
  const failure = loadFailed("rewards", error, COPY);
218
- return { ...s, ...PRIVATE_EMPTY, currency, earningRules: [], earningEmptyText: "", inactiveText: "", hideCatalog: !failure.isMock, hideEarningHeading: true, rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: !failure.isMock,
244
+ return { ...s, ...PRIVATE_EMPTY, ...OFFERING_EMPTY, currency, earningRules: [], earningEmptyText: "", inactiveText: "", hideCatalog: !failure.isMock, hideEarningHeading: true, rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: !failure.isMock,
219
245
  ...failure, signInRequired: false, noSignIn: true };
220
246
  }
221
247
  },