@wix/himalaya-cli 0.1388.0 → 0.1390.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/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +24 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +57 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +57 -0
- package/dist/cli.mjs +7 -7
- package/guides/iap-screen-adoption.md +53 -1
- package/guides/sdk-reference.json +8 -2
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +193 -1
- package/templates/beauty-salon/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/beauty-salon/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/beauty-salon-owner/config.ts +11 -0
- package/templates/beauty-salon-owner/dev/index.ts +540 -0
- package/templates/beauty-salon-owner/himalaya.content.json +6 -0
- package/templates/beauty-salon-owner/platforms.json +2 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/branded-lite/dev/index.ts +194 -2
- package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/branded-lite/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/community-club/dev/index.ts +193 -1
- package/templates/community-club/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/community-club/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/community-club-owner/config.ts +11 -0
- package/templates/community-club-owner/dev/index.ts +540 -0
- package/templates/community-club-owner/himalaya.content.json +6 -0
- package/templates/community-club-owner/platforms.json +2 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/events-meetups/dev/index.ts +193 -1
- package/templates/events-meetups/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/events-meetups/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/events-meetups-owner/config.ts +11 -0
- package/templates/events-meetups-owner/dev/index.ts +540 -0
- package/templates/events-meetups-owner/himalaya.content.json +6 -0
- package/templates/events-meetups-owner/platforms.json +2 -1
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/gym-fitness/dev/index.ts +193 -1
- package/templates/gym-fitness/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/gym-fitness/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/gym-fitness-owner/config.ts +11 -0
- package/templates/gym-fitness-owner/dev/index.ts +540 -0
- package/templates/gym-fitness-owner/himalaya.content.json +6 -0
- package/templates/gym-fitness-owner/platforms.json +2 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/nonprofit-cause/dev/index.ts +193 -1
- package/templates/nonprofit-cause/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/nonprofit-cause/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/nonprofit-cause-owner/config.ts +11 -0
- package/templates/nonprofit-cause-owner/dev/index.ts +540 -0
- package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
- package/templates/nonprofit-cause-owner/platforms.json +2 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/owner-lite/config.ts +11 -0
- package/templates/owner-lite/dev/index.ts +540 -0
- package/templates/owner-lite/himalaya.content.json +6 -0
- package/templates/owner-lite/platforms.json +2 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/podcast-creator/dev/index.ts +193 -1
- package/templates/podcast-creator/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/podcast-creator/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/podcast-creator-owner/config.ts +11 -0
- package/templates/podcast-creator-owner/dev/index.ts +540 -0
- package/templates/podcast-creator-owner/himalaya.content.json +6 -0
- package/templates/podcast-creator-owner/platforms.json +2 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/portfolio-creative/dev/index.ts +193 -1
- package/templates/portfolio-creative/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/portfolio-creative/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/portfolio-creative-owner/config.ts +11 -0
- package/templates/portfolio-creative-owner/dev/index.ts +540 -0
- package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
- package/templates/portfolio-creative-owner/platforms.json +2 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/restaurant/dev/index.ts +193 -1
- package/templates/restaurant/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/restaurant/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/restaurant-owner/config.ts +11 -0
- package/templates/restaurant-owner/dev/index.ts +540 -0
- package/templates/restaurant-owner/himalaya.content.json +6 -0
- package/templates/restaurant-owner/platforms.json +2 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/retail-store/dev/index.ts +193 -1
- package/templates/retail-store/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/retail-store/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/retail-store-owner/config.ts +11 -0
- package/templates/retail-store-owner/dev/index.ts +540 -0
- package/templates/retail-store-owner/himalaya.content.json +6 -0
- package/templates/retail-store-owner/platforms.json +2 -1
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
- package/templates/yoga-studio/dev/index.ts +193 -1
- package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +84 -3
- package/templates/yoga-studio/tier5-src/himi-store-account/index.ts +36 -1
- package/templates/yoga-studio-owner/config.ts +11 -0
- package/templates/yoga-studio-owner/dev/index.ts +540 -0
- package/templates/yoga-studio-owner/himalaya.content.json +6 -0
- package/templates/yoga-studio-owner/platforms.json +2 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-wrist/index.ts +350 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Tier-5 bundle: __HIMI_APP_NAME__ — WRIST. The worker behind the paired Apple Watch's two
|
|
2
|
+
// screens, run headlessly ON THE PHONE by the app's watch brain (companion mode): the watch
|
|
3
|
+
// never holds a Wix session and never runs JS, it renders the literal descriptor the phone
|
|
4
|
+
// resolves from this state.
|
|
5
|
+
//
|
|
6
|
+
// Two owner moments, nothing else: "who's next and when" (nextUp + the rest of today) and
|
|
7
|
+
// "accept or decline a booking request" (requests, one at a time). Confirm / decline call
|
|
8
|
+
// the same @himalaya/wix-bookings writes the Wix Owner booking screen uses, but per request:
|
|
9
|
+
// that worker's actions read a screen-param id and a single screen revision, and the wrist
|
|
10
|
+
// holds a QUEUE of requests, each with its own revision.
|
|
11
|
+
//
|
|
12
|
+
// Every write carries the siteId the watch rendered: a site switched on the phone between
|
|
13
|
+
// render and tap must never confirm another business's booking.
|
|
14
|
+
|
|
15
|
+
import { defineService } from "@himalaya/state";
|
|
16
|
+
import { queryBookings, confirmBooking, declineBooking } from "@himalaya/wix-bookings";
|
|
17
|
+
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
18
|
+
import { readSelectedSite, selectedSiteName } from "../_shared/owner-selected-site.js";
|
|
19
|
+
|
|
20
|
+
type Rec = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
export interface WristRow {
|
|
23
|
+
bookingId: string;
|
|
24
|
+
name: string; // client first name (or "Client")
|
|
25
|
+
service: string; // booked service title
|
|
26
|
+
startIso: string;
|
|
27
|
+
endIso: string;
|
|
28
|
+
timeLabel: string; // "10:30"
|
|
29
|
+
rangeLabel: string; // "10:30 – 11:30"
|
|
30
|
+
countdown: string; // "in 21 min" / "in 1 h 5 min" / "now" — relative to the last load
|
|
31
|
+
whenLabel: string; // "Today" / "Tomorrow" / "Thu 16 Oct" — the day it falls on
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WristRequest extends WristRow {
|
|
35
|
+
revision: string;
|
|
36
|
+
durationMin: number;
|
|
37
|
+
dayLabel: string; // "Thu 14:00"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WristState {
|
|
41
|
+
status: "loading" | "ready" | "error" | "signedOut" | "noBookings";
|
|
42
|
+
siteId: string;
|
|
43
|
+
siteName: string;
|
|
44
|
+
/** Always an object — an absent one is EMPTY_ROW behind `hasNextUp` — so every binding
|
|
45
|
+
* path resolves on every state (a null parent left them unresolved). */
|
|
46
|
+
nextUp: WristRow;
|
|
47
|
+
hasNextUp: boolean;
|
|
48
|
+
later: WristRow[];
|
|
49
|
+
requests: WristRequest[];
|
|
50
|
+
requestCount: number;
|
|
51
|
+
requestBadge: string; // "2 requests" / "1 request" / ""
|
|
52
|
+
hasRequests: boolean;
|
|
53
|
+
noRequests: boolean;
|
|
54
|
+
/** Hide "No requests waiting": there are requests, or the screen is a degrade state. */
|
|
55
|
+
hideNoRequests: boolean;
|
|
56
|
+
requestIndex: number;
|
|
57
|
+
requestPosition: string; // "Request 1 of 2"
|
|
58
|
+
current: WristRequest;
|
|
59
|
+
doneToday: boolean;
|
|
60
|
+
tomorrowFirst: WristRow;
|
|
61
|
+
hasTomorrowFirst: boolean;
|
|
62
|
+
/** Hide the "next booking" block: there is a next-up today, or nothing upcoming at all. */
|
|
63
|
+
hideTomorrow: boolean;
|
|
64
|
+
inFlight: boolean;
|
|
65
|
+
/** Decline is two taps: `askDecline` reveals the confirm row, `decline` writes. */
|
|
66
|
+
confirmingDecline: boolean;
|
|
67
|
+
notConfirmingDecline: boolean;
|
|
68
|
+
actionError: string;
|
|
69
|
+
message: string; // Home's one-line state copy (incl. "You're done for today")
|
|
70
|
+
/** Only the copy for a state the owner must act on (signed out, no Bookings, error) — the
|
|
71
|
+
* request screen's line, where "done for today" would be wrong. */
|
|
72
|
+
statusMessage: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const COPY = {
|
|
76
|
+
signedOut: "Sign in on your iPhone",
|
|
77
|
+
noBookings: "Bookings isn't set up on this site",
|
|
78
|
+
doneToday: "You're done for today",
|
|
79
|
+
noRequests: "No requests waiting",
|
|
80
|
+
changed: "This request changed — check it on your iPhone",
|
|
81
|
+
error: "Open Owner on your iPhone",
|
|
82
|
+
writeFailed: "Couldn't update it — check it on your iPhone",
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
const PENDING = new Set(["PENDING", "PENDING_APPROVAL", "WAITING_LIST"]);
|
|
86
|
+
const LIMIT = 50;
|
|
87
|
+
|
|
88
|
+
function str(r: Rec | undefined, ...paths: string[]): string {
|
|
89
|
+
for (const path of paths) {
|
|
90
|
+
let v: unknown = r;
|
|
91
|
+
for (const k of path.split(".")) v = v && typeof v === "object" ? (v as Rec)[k] : undefined;
|
|
92
|
+
if (typeof v === "string" && v) return v;
|
|
93
|
+
if (typeof v === "number") return String(v);
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hhmm(d: Date): string {
|
|
99
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
103
|
+
|
|
104
|
+
function sameLocalDay(a: Date, b: Date): boolean {
|
|
105
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** "in 21 min" / "in 1 h 5 min" / "now". The phone reloads while the watch is visible, so a
|
|
109
|
+
* minute-granularity label stays honest without an OS-ticked timer on the wrist. */
|
|
110
|
+
export function countdown(startMs: number, nowMs: number): string {
|
|
111
|
+
const minutes = Math.round((startMs - nowMs) / 60000);
|
|
112
|
+
if (minutes <= 0) return "now";
|
|
113
|
+
if (minutes < 60) return `in ${minutes} min`;
|
|
114
|
+
const h = Math.floor(minutes / 60), m = minutes % 60;
|
|
115
|
+
return m ? `in ${h} h ${m} min` : `in ${h} h`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function row(b: Rec, nowMs = Date.now()): WristRow {
|
|
119
|
+
const startIso = str(b, "startDate", "bookedEntity.slot.startDate");
|
|
120
|
+
const endIso = str(b, "endDate", "bookedEntity.slot.endDate");
|
|
121
|
+
const start = new Date(startIso);
|
|
122
|
+
const end = endIso ? new Date(endIso) : null;
|
|
123
|
+
return {
|
|
124
|
+
bookingId: str(b, "id"),
|
|
125
|
+
name: str(b, "contactDetails.firstName") || "Client",
|
|
126
|
+
service: str(b, "bookedEntity.title", "bookedEntity.slot.serviceName") || "Appointment",
|
|
127
|
+
startIso,
|
|
128
|
+
endIso,
|
|
129
|
+
timeLabel: hhmm(start),
|
|
130
|
+
rangeLabel: end ? `${hhmm(start)} – ${hhmm(end)}` : hhmm(start),
|
|
131
|
+
countdown: countdown(start.getTime(), nowMs),
|
|
132
|
+
whenLabel: whenLabel(start, new Date(nowMs)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
137
|
+
|
|
138
|
+
/** The day a booking falls on, named as an owner would say it. */
|
|
139
|
+
export function whenLabel(start: Date, now: Date): string {
|
|
140
|
+
if (sameLocalDay(start, now)) return "Today";
|
|
141
|
+
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
142
|
+
if (sameLocalDay(start, tomorrow)) return "Tomorrow";
|
|
143
|
+
return `${WEEKDAYS[start.getDay()]} ${start.getDate()} ${MONTHS[start.getMonth()]}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function request(b: Rec): WristRequest {
|
|
147
|
+
const r = row(b);
|
|
148
|
+
const start = new Date(r.startIso);
|
|
149
|
+
const durationMin = r.endIso ? Math.max(0, Math.round((Date.parse(r.endIso) - start.getTime()) / 60000)) : 0;
|
|
150
|
+
return { ...r, revision: str(b, "revision"), durationMin, dayLabel: `${WEEKDAYS[start.getDay()]} ${hhmm(start)}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const EMPTY_ROW: WristRow = { bookingId: "", name: "", service: "", startIso: "", endIso: "", timeLabel: "", rangeLabel: "", countdown: "", whenLabel: "" };
|
|
154
|
+
const EMPTY_REQUEST: WristRequest = { ...EMPTY_ROW, revision: "", durationMin: 0, dayLabel: "" };
|
|
155
|
+
|
|
156
|
+
/** Pure: shape a bookings page into the wrist state. `now` is injectable for tests. */
|
|
157
|
+
export function shape(bookings: Rec[], now: Date, site: { id: string; name: string }, requestIndex = 0): WristState {
|
|
158
|
+
const upcoming = bookings
|
|
159
|
+
.filter((b) => str(b, "id") && Date.parse(str(b, "startDate", "bookedEntity.slot.startDate")) >= now.getTime())
|
|
160
|
+
.sort((a, b) => Date.parse(str(a, "startDate")) - Date.parse(str(b, "startDate")));
|
|
161
|
+
const confirmed = upcoming.filter((b) => str(b, "status") === "CONFIRMED");
|
|
162
|
+
const today = confirmed.filter((b) => sameLocalDay(new Date(str(b, "startDate")), now)).map((b) => row(b, now.getTime()));
|
|
163
|
+
// A request this worker just confirmed/declined can still come back PENDING at the same
|
|
164
|
+
// revision from a read that lagged the write; it is not waiting on the owner any more.
|
|
165
|
+
const requests = upcoming
|
|
166
|
+
.filter((b) => PENDING.has(str(b, "status")) && handled.get(str(b, "id")) !== str(b, "revision"))
|
|
167
|
+
.map(request);
|
|
168
|
+
const next = today[0];
|
|
169
|
+
const after = next ? undefined : confirmed.map((b) => row(b, now.getTime()))[0];
|
|
170
|
+
const index = requests.length ? Math.min(Math.max(0, requestIndex), requests.length - 1) : 0;
|
|
171
|
+
const current = requests[index];
|
|
172
|
+
return {
|
|
173
|
+
status: "ready",
|
|
174
|
+
siteId: site.id,
|
|
175
|
+
siteName: site.name,
|
|
176
|
+
nextUp: next ?? EMPTY_ROW,
|
|
177
|
+
hasNextUp: !!next,
|
|
178
|
+
later: today.slice(1),
|
|
179
|
+
requests,
|
|
180
|
+
requestCount: requests.length,
|
|
181
|
+
requestBadge: requests.length === 0 ? "" : `${requests.length} ${requests.length === 1 ? "request" : "requests"}`,
|
|
182
|
+
hasRequests: requests.length > 0,
|
|
183
|
+
noRequests: requests.length === 0,
|
|
184
|
+
hideNoRequests: requests.length > 0,
|
|
185
|
+
requestIndex: index,
|
|
186
|
+
requestPosition: current ? `Request ${index + 1} of ${requests.length}` : "",
|
|
187
|
+
current: current ?? EMPTY_REQUEST,
|
|
188
|
+
doneToday: !next,
|
|
189
|
+
tomorrowFirst: after ?? EMPTY_ROW,
|
|
190
|
+
hasTomorrowFirst: !!after,
|
|
191
|
+
hideTomorrow: !!next || !after,
|
|
192
|
+
inFlight: false,
|
|
193
|
+
confirmingDecline: false,
|
|
194
|
+
notConfirmingDecline: true,
|
|
195
|
+
actionError: "",
|
|
196
|
+
message: next ? "" : COPY.doneToday,
|
|
197
|
+
statusMessage: "",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type Host = typeof globalThis & { himalaya?: { input?: Record<string, unknown> } };
|
|
202
|
+
|
|
203
|
+
/** The business this worker's Wix bridge is scoped to. The phone's watch brain passes it as
|
|
204
|
+
* `ownerWristSite` — the same value it checks every Accept/Decline against — so the two can
|
|
205
|
+
* never disagree (the picker's KV entry is written only by the Sites list, not by a push or
|
|
206
|
+
* deep link that also switches business). The bridge-injected business and the picker entry
|
|
207
|
+
* are fallbacks for a host that does not pass it. */
|
|
208
|
+
function site(): { id: string; name: string } {
|
|
209
|
+
const input = (globalThis as Host).himalaya?.input ?? {};
|
|
210
|
+
const pick = (v: unknown) => (typeof v === "string" && v !== "default" ? v.trim() : "");
|
|
211
|
+
const picked = readSelectedSite();
|
|
212
|
+
const id = pick(input.ownerWristSite) || pick(input.__himiPreferenceBusiness) || picked?.id || "";
|
|
213
|
+
return { id, name: picked && picked.id === id ? selectedSiteName("") : "" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function initial(): WristState {
|
|
217
|
+
return { ...shape([], new Date(), site()), status: "loading", message: "" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A failed read shows NOTHING of the last one: after a sign-out or a site switch those rows
|
|
221
|
+
* are someone else's clients. Only the site header survives. */
|
|
222
|
+
/** No usable owner session behind the bridge. On a phone that reads as the bridge refusing
|
|
223
|
+
* ("wixApi not enabled — …") or a 401/403; the web preview's 501 bridge shape counts too. A
|
|
224
|
+
* transient 5xx from Wix is NOT a sign-out — it is an ordinary error. */
|
|
225
|
+
export function isSignedOut(e: unknown): boolean {
|
|
226
|
+
const text = String((e as Error)?.message ?? e);
|
|
227
|
+
if (/wixApi not enabled|session unavailable|not signed in|\bHTTP 40[13]\b|webwix_bridge/i.test(text)) return true;
|
|
228
|
+
return isBridgeUnavailable(e) && /\bHTTP 501\b/.test(text);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function failure(s: WristState, e: unknown): WristState {
|
|
232
|
+
// A degrade screen is ONE line: no "Next up" card, no "No requests waiting" under it.
|
|
233
|
+
const empty = { ...shape([], new Date(), { id: s.siteId, name: s.siteName }), message: "", hideNoRequests: true, hideTomorrow: true };
|
|
234
|
+
if (isSignedOut(e)) return { ...empty, status: "signedOut", message: COPY.signedOut, statusMessage: COPY.signedOut };
|
|
235
|
+
const text = String((e as Error)?.message ?? e);
|
|
236
|
+
// The Bookings app is not installed on the site: its API answers 428 (or 404 for the route).
|
|
237
|
+
if (/\b428\b|\b404\b|not installed|FAILED_PRECONDITION/i.test(text)) {
|
|
238
|
+
return { ...empty, status: "noBookings", message: COPY.noBookings, statusMessage: COPY.noBookings };
|
|
239
|
+
}
|
|
240
|
+
return { ...empty, status: "error", message: COPY.error, statusMessage: COPY.error };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function fetchInto(s: WristState): Promise<WristState> {
|
|
244
|
+
const nowIso = new Date().toISOString();
|
|
245
|
+
const sort = [{ fieldName: "startDate", order: "ASC" }];
|
|
246
|
+
// TWO reads: the day's schedule, and the pending requests on their own. One page of every
|
|
247
|
+
// status would fill with a busy week's confirmed bookings and hide next week's request.
|
|
248
|
+
const [schedule, pending] = await Promise.all([
|
|
249
|
+
queryBookings({ query: { filter: { startDate: { $gte: nowIso }, status: { $nin: ["CANCELED", "DECLINED"] } }, sort, paging: { limit: LIMIT, offset: 0 } } }),
|
|
250
|
+
queryBookings({ query: { filter: { startDate: { $gte: nowIso }, status: { $in: [...PENDING] } }, sort, paging: { limit: LIMIT, offset: 0 } } }),
|
|
251
|
+
]);
|
|
252
|
+
const byId = new Map<string, Rec>();
|
|
253
|
+
for (const b of [...(schedule.bookings ?? []), ...(pending.bookings ?? [])] as unknown as Rec[]) {
|
|
254
|
+
const id = str(b, "id");
|
|
255
|
+
if (id) byId.set(id, b);
|
|
256
|
+
}
|
|
257
|
+
return shape([...byId.values()], new Date(), site(), s.requestIndex);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isStale(e: unknown): boolean {
|
|
261
|
+
return /revision|\b409\b|ABORTED|conflict/i.test(String((e as Error)?.message ?? e));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type Write = (bookingId: string, revision: string) => Promise<unknown>;
|
|
265
|
+
|
|
266
|
+
async function respond(s: WristState, params: unknown, write: Write): Promise<WristState> {
|
|
267
|
+
if (s.inFlight) return s;
|
|
268
|
+
const p = (params && typeof params === "object" ? params : {}) as Rec;
|
|
269
|
+
const bookingId = str(p, "bookingId");
|
|
270
|
+
const siteId = str(p, "siteId");
|
|
271
|
+
// A press rendered for another business, or for a request that is no longer pending.
|
|
272
|
+
if (siteId !== site().id) return { ...s, actionError: COPY.changed };
|
|
273
|
+
const target = s.requests.find((r) => r.bookingId === bookingId);
|
|
274
|
+
if (!target) return { ...s, actionError: COPY.changed };
|
|
275
|
+
inFlightNow = true;
|
|
276
|
+
writeGeneration += 1;
|
|
277
|
+
try {
|
|
278
|
+
await write(target.bookingId, target.revision);
|
|
279
|
+
handled.set(target.bookingId, target.revision);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
inFlightNow = false;
|
|
282
|
+
if (isStale(e)) {
|
|
283
|
+
try { return (lastWrite = { ...(await fetchInto(s)), actionError: COPY.changed }); }
|
|
284
|
+
catch { return (lastWrite = { ...s, actionError: COPY.changed }); }
|
|
285
|
+
}
|
|
286
|
+
// Not written. A lost session is a sign-out; anything else keeps the request on screen
|
|
287
|
+
// with an error under its buttons, rather than emptying the queue as if it had worked.
|
|
288
|
+
return (lastWrite = isSignedOut(e) ? failure(s, e) : { ...s, actionError: COPY.writeFailed });
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
return (lastWrite = await fetchInto(s));
|
|
292
|
+
} catch (e) {
|
|
293
|
+
return (lastWrite = failure(s, e));
|
|
294
|
+
} finally {
|
|
295
|
+
inFlightNow = false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Requests this worker wrote, with the revision it wrote against (see `shape`). */
|
|
300
|
+
const handled = new Map<string, string>();
|
|
301
|
+
|
|
302
|
+
// The runtime commits whichever overlapping action finishes LAST, with the state each was
|
|
303
|
+
// dispatched with. A refresh that started before a confirm and lands after it would put the
|
|
304
|
+
// accepted request back on the wrist, so a refresh overtaken by a write returns that write's
|
|
305
|
+
// result instead of its own.
|
|
306
|
+
let writeGeneration = 0;
|
|
307
|
+
let lastWrite: WristState | null = null;
|
|
308
|
+
|
|
309
|
+
async function read(s: WristState): Promise<WristState> {
|
|
310
|
+
const generation = writeGeneration;
|
|
311
|
+
try {
|
|
312
|
+
const next = await fetchInto(s);
|
|
313
|
+
return generation !== writeGeneration || inFlightNow ? (lastWrite ?? s) : next;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
return generation !== writeGeneration || inFlightNow ? (lastWrite ?? s) : failure(s, e);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The state a concurrent action sees is the one it was dispatched with, so a double tap
|
|
320
|
+
// would read `inFlight: false` twice; this module flag is the guard that actually holds.
|
|
321
|
+
let inFlightNow = false;
|
|
322
|
+
|
|
323
|
+
const svc = defineService<WristState, {
|
|
324
|
+
load: (s: WristState) => Promise<WristState>;
|
|
325
|
+
refresh: (s: WristState) => Promise<WristState>;
|
|
326
|
+
confirm: (s: WristState, params?: unknown) => Promise<WristState>;
|
|
327
|
+
decline: (s: WristState, params?: unknown) => Promise<WristState>;
|
|
328
|
+
nextRequest: (s: WristState) => Promise<WristState>;
|
|
329
|
+
askDecline: (s: WristState) => Promise<WristState>;
|
|
330
|
+
cancelDecline: (s: WristState) => Promise<WristState>;
|
|
331
|
+
}>("ownerWrist", {
|
|
332
|
+
state: initial(),
|
|
333
|
+
actions: {
|
|
334
|
+
load: async (s) => read(s),
|
|
335
|
+
refresh: async (s) => read(s),
|
|
336
|
+
confirm: async (s, params) => (inFlightNow ? s : respond(s, params, (id, rev) => confirmBooking(id, rev))),
|
|
337
|
+
decline: async (s, params) => (inFlightNow ? s : respond(s, params, (id, rev) => declineBooking(id, rev))),
|
|
338
|
+
askDecline: async (s) => (s.current.bookingId ? { ...s, confirmingDecline: true, notConfirmingDecline: false, actionError: "" } : s),
|
|
339
|
+
cancelDecline: async (s) => ({ ...s, confirmingDecline: false, notConfirmingDecline: true }),
|
|
340
|
+
nextRequest: async (s) => {
|
|
341
|
+
if (!s.requests.length) return s;
|
|
342
|
+
const index = (s.requestIndex + 1) % s.requests.length;
|
|
343
|
+
const current = s.requests[index];
|
|
344
|
+
return { ...s, requestIndex: index, current, requestPosition: `Request ${index + 1} of ${s.requests.length}`, actionError: "" };
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
svc.load();
|
|
350
|
+
export { svc };
|
|
@@ -2762,6 +2762,93 @@ const SCREENS = runtimeSetting({
|
|
|
2762
2762
|
"accessibilityLabel": "Saved posts"
|
|
2763
2763
|
}
|
|
2764
2764
|
},
|
|
2765
|
+
{
|
|
2766
|
+
"id": "himi_account.restore",
|
|
2767
|
+
"body": {
|
|
2768
|
+
"package": "himalaya",
|
|
2769
|
+
"type": "HStack",
|
|
2770
|
+
"props": {
|
|
2771
|
+
"spacing": 12,
|
|
2772
|
+
"children": [
|
|
2773
|
+
{
|
|
2774
|
+
"id": "",
|
|
2775
|
+
"body": {
|
|
2776
|
+
"package": "himalaya",
|
|
2777
|
+
"type": "Icon",
|
|
2778
|
+
"props": {
|
|
2779
|
+
"name": "refresh",
|
|
2780
|
+
"size": 18,
|
|
2781
|
+
"tint": "accentPrimary"
|
|
2782
|
+
}
|
|
2783
|
+
},
|
|
2784
|
+
"modifiers": {
|
|
2785
|
+
"accessibilityRole": "decorative"
|
|
2786
|
+
}
|
|
2787
|
+
},
|
|
2788
|
+
{
|
|
2789
|
+
"id": "",
|
|
2790
|
+
"body": {
|
|
2791
|
+
"package": "himalaya",
|
|
2792
|
+
"type": "Text",
|
|
2793
|
+
"props": {
|
|
2794
|
+
"text": "$service:himiStoreAccount.restoreLabel",
|
|
2795
|
+
"style": "bodyEmphasis"
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
},
|
|
2799
|
+
{
|
|
2800
|
+
"id": "",
|
|
2801
|
+
"body": {
|
|
2802
|
+
"package": "himalaya",
|
|
2803
|
+
"type": "Spacer",
|
|
2804
|
+
"props": {}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
]
|
|
2808
|
+
}
|
|
2809
|
+
},
|
|
2810
|
+
"modifiers": {
|
|
2811
|
+
"background": {
|
|
2812
|
+
"token": "surfaceElevated"
|
|
2813
|
+
},
|
|
2814
|
+
"border": {
|
|
2815
|
+
"width": 1,
|
|
2816
|
+
"color": {
|
|
2817
|
+
"token": "border"
|
|
2818
|
+
}
|
|
2819
|
+
},
|
|
2820
|
+
"cornerRadius": 8,
|
|
2821
|
+
"padding": {
|
|
2822
|
+
"leading": 14,
|
|
2823
|
+
"trailing": 14,
|
|
2824
|
+
"top": 13,
|
|
2825
|
+
"bottom": 13
|
|
2826
|
+
},
|
|
2827
|
+
"onTap": {
|
|
2828
|
+
"actionId": "tier5.himiStoreAccount.iapRestore"
|
|
2829
|
+
},
|
|
2830
|
+
"pressFeedback": "scale",
|
|
2831
|
+
"haptic": "light",
|
|
2832
|
+
"accessibilityLabel": "$service:himiStoreAccount.restoreLabel",
|
|
2833
|
+
"hiddenPath": "$service:himiStoreAccount.restoreHidden"
|
|
2834
|
+
}
|
|
2835
|
+
},
|
|
2836
|
+
{
|
|
2837
|
+
"id": "himi_account.restore_message",
|
|
2838
|
+
"body": {
|
|
2839
|
+
"package": "himalaya",
|
|
2840
|
+
"type": "Text",
|
|
2841
|
+
"props": {
|
|
2842
|
+
"text": "$service:himiStoreAccount.restoreMessage",
|
|
2843
|
+
"style": "footnote",
|
|
2844
|
+
"color": "textSecondary",
|
|
2845
|
+
"collapseWhenEmpty": true
|
|
2846
|
+
}
|
|
2847
|
+
},
|
|
2848
|
+
"modifiers": {
|
|
2849
|
+
"hiddenPath": "$service:himiStoreAccount.restoreHidden"
|
|
2850
|
+
}
|
|
2851
|
+
},
|
|
2765
2852
|
{
|
|
2766
2853
|
"id": "himi_account.addresses",
|
|
2767
2854
|
"body": {
|
|
@@ -3102,6 +3189,109 @@ const SCREENS = runtimeSetting({
|
|
|
3102
3189
|
"hiddenPath": "$service:himiStoreAccount.notMember"
|
|
3103
3190
|
}
|
|
3104
3191
|
},
|
|
3192
|
+
{
|
|
3193
|
+
"id": "",
|
|
3194
|
+
"body": {
|
|
3195
|
+
"package": "himalaya",
|
|
3196
|
+
"type": "VStack",
|
|
3197
|
+
"props": {
|
|
3198
|
+
"spacing": 8,
|
|
3199
|
+
"alignment": "leading",
|
|
3200
|
+
"children": [
|
|
3201
|
+
{
|
|
3202
|
+
"id": "himi_account.restore_guest",
|
|
3203
|
+
"body": {
|
|
3204
|
+
"package": "himalaya",
|
|
3205
|
+
"type": "HStack",
|
|
3206
|
+
"props": {
|
|
3207
|
+
"spacing": 12,
|
|
3208
|
+
"children": [
|
|
3209
|
+
{
|
|
3210
|
+
"id": "",
|
|
3211
|
+
"body": {
|
|
3212
|
+
"package": "himalaya",
|
|
3213
|
+
"type": "Icon",
|
|
3214
|
+
"props": {
|
|
3215
|
+
"name": "refresh",
|
|
3216
|
+
"size": 18,
|
|
3217
|
+
"tint": "accentPrimary"
|
|
3218
|
+
}
|
|
3219
|
+
},
|
|
3220
|
+
"modifiers": {
|
|
3221
|
+
"accessibilityRole": "decorative"
|
|
3222
|
+
}
|
|
3223
|
+
},
|
|
3224
|
+
{
|
|
3225
|
+
"id": "",
|
|
3226
|
+
"body": {
|
|
3227
|
+
"package": "himalaya",
|
|
3228
|
+
"type": "Text",
|
|
3229
|
+
"props": {
|
|
3230
|
+
"text": "$service:himiStoreAccount.restoreLabel",
|
|
3231
|
+
"style": "bodyEmphasis"
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
},
|
|
3235
|
+
{
|
|
3236
|
+
"id": "",
|
|
3237
|
+
"body": {
|
|
3238
|
+
"package": "himalaya",
|
|
3239
|
+
"type": "Spacer",
|
|
3240
|
+
"props": {}
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
]
|
|
3244
|
+
}
|
|
3245
|
+
},
|
|
3246
|
+
"modifiers": {
|
|
3247
|
+
"background": {
|
|
3248
|
+
"token": "surfaceElevated"
|
|
3249
|
+
},
|
|
3250
|
+
"border": {
|
|
3251
|
+
"width": 1,
|
|
3252
|
+
"color": {
|
|
3253
|
+
"token": "border"
|
|
3254
|
+
}
|
|
3255
|
+
},
|
|
3256
|
+
"cornerRadius": 8,
|
|
3257
|
+
"padding": {
|
|
3258
|
+
"leading": 14,
|
|
3259
|
+
"trailing": 14,
|
|
3260
|
+
"top": 13,
|
|
3261
|
+
"bottom": 13
|
|
3262
|
+
},
|
|
3263
|
+
"onTap": {
|
|
3264
|
+
"actionId": "tier5.himiStoreAccount.iapRestore"
|
|
3265
|
+
},
|
|
3266
|
+
"pressFeedback": "scale",
|
|
3267
|
+
"haptic": "light",
|
|
3268
|
+
"accessibilityLabel": "$service:himiStoreAccount.restoreLabel",
|
|
3269
|
+
"hiddenPath": "$service:himiStoreAccount.restoreHidden"
|
|
3270
|
+
}
|
|
3271
|
+
},
|
|
3272
|
+
{
|
|
3273
|
+
"id": "himi_account.restore_guest_message",
|
|
3274
|
+
"body": {
|
|
3275
|
+
"package": "himalaya",
|
|
3276
|
+
"type": "Text",
|
|
3277
|
+
"props": {
|
|
3278
|
+
"text": "$service:himiStoreAccount.restoreMessage",
|
|
3279
|
+
"style": "footnote",
|
|
3280
|
+
"color": "textSecondary",
|
|
3281
|
+
"collapseWhenEmpty": true
|
|
3282
|
+
}
|
|
3283
|
+
},
|
|
3284
|
+
"modifiers": {
|
|
3285
|
+
"hiddenPath": "$service:himiStoreAccount.restoreHidden"
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
]
|
|
3289
|
+
}
|
|
3290
|
+
},
|
|
3291
|
+
"modifiers": {
|
|
3292
|
+
"hiddenPath": "$service:himiStoreAccount.isMember"
|
|
3293
|
+
}
|
|
3294
|
+
},
|
|
3105
3295
|
{
|
|
3106
3296
|
"id": "himi_account.language",
|
|
3107
3297
|
"body": {
|
|
@@ -3471,6 +3661,7 @@ const SCREENS = runtimeSetting({
|
|
|
3471
3661
|
"actionId": "tier5.himiStoreAccount.refresh",
|
|
3472
3662
|
"params": {
|
|
3473
3663
|
"memberAccountLinks": "1",
|
|
3664
|
+
"restorePurchases": "1",
|
|
3474
3665
|
"copy": "{\"account.subtitle\":\"Orders, RSVPs and saved content in one place.\",\"storeAccount.authBody\":\"Sign in to continue — cart and RSVPs stay with you.\",\"storeAccount.authBodyRegister\":\"Create a member account to continue — cart and RSVPs stay with you.\"}"
|
|
3475
3666
|
}
|
|
3476
3667
|
}
|
|
@@ -3482,7 +3673,8 @@ const SCREENS = runtimeSetting({
|
|
|
3482
3673
|
"contentType": "application/javascript+ts-bundle",
|
|
3483
3674
|
"declaredCapabilities": [
|
|
3484
3675
|
"wix_api",
|
|
3485
|
-
"app_info"
|
|
3676
|
+
"app_info",
|
|
3677
|
+
"iap"
|
|
3486
3678
|
],
|
|
3487
3679
|
"minBinaryVersion": "0.2.0",
|
|
3488
3680
|
"bundleVersion": "0.0.1"
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// `@himalaya/iap-checkout` → createIapCommerce), shared by every screen that shows or sells an
|
|
3
3
|
// IAP-backed item. Mapping, Apple prices, payment guards, the host scope and the sale live in the
|
|
4
4
|
// SDK; this only supplies the copy and the hydration reset.
|
|
5
|
-
import type
|
|
6
|
-
import type { IapPurchaseCopy } from "@himalaya/iap-checkout";
|
|
7
|
-
import { copyParam } from "./himi-copy.js";
|
|
5
|
+
import { hostSessionFromGlobal, type IapPriceCopy } from "@himalaya/iap";
|
|
6
|
+
import type { IapCommerce, IapPurchaseCopy, IapRestoreResult } from "@himalaya/iap-checkout";
|
|
7
|
+
import { copyParam, copyTemplate } from "./himi-copy.js";
|
|
8
8
|
|
|
9
9
|
/** Price labels from the screen's copy pack (`<prefix>.priceLoadingText`, …). */
|
|
10
10
|
export function iapPriceCopy(prefix: string): IapPriceCopy {
|
|
@@ -31,6 +31,87 @@ export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The Restore purchases control (App Review 3.1.1) a screen binds. */
|
|
35
|
+
export interface IapRestoreFields {
|
|
36
|
+
restoreHidden: boolean;
|
|
37
|
+
restoreLabel: string;
|
|
38
|
+
restoreDisabled: boolean;
|
|
39
|
+
restoreMessage: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep these out of hydration: a restore's answer belongs to the run that got it. */
|
|
43
|
+
export const IAP_RESTORE_KEYS = ["restoreHidden", "restoreLabel", "restoreDisabled", "restoreMessage"] as const;
|
|
44
|
+
|
|
45
|
+
/** The control before the screen knows it sells through Apple. */
|
|
46
|
+
export const NO_IAP_RESTORE: IapRestoreFields = { restoreHidden: true, restoreLabel: "", restoreDisabled: false, restoreMessage: "" };
|
|
47
|
+
|
|
48
|
+
export interface IapRestoreControl {
|
|
49
|
+
/** `sellsThroughApple` is the screen's own signal: its item's App Store route, or Account's
|
|
50
|
+
* purchase readiness. */
|
|
51
|
+
fields(sellsThroughApple: boolean): IapRestoreFields;
|
|
52
|
+
/** Runs the shared `commerce.restore()`, repainting as it starts and when it answers. A tap while
|
|
53
|
+
* one runs is ignored. */
|
|
54
|
+
run(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Copy from the screen's pack (`<prefix>.restorePurchasesCta`, …). The restore itself, and the access
|
|
58
|
+
* re-read after it, are the shared commerce's. */
|
|
59
|
+
export function createIapRestore(
|
|
60
|
+
commerce: Pick<IapCommerce, "restore">,
|
|
61
|
+
options: { prefix: string; repaint(): void; /** The session generation; defaults to the host's. */ generation?: () => number },
|
|
62
|
+
): IapRestoreControl {
|
|
63
|
+
const generation = options.generation ?? (() => hostSessionFromGlobal().current().generation);
|
|
64
|
+
let running = false;
|
|
65
|
+
let message = "";
|
|
66
|
+
let messageGeneration = -1;
|
|
67
|
+
|
|
68
|
+
function say(text: string, forGeneration: number): void {
|
|
69
|
+
message = text;
|
|
70
|
+
messageGeneration = forGeneration;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function run(): Promise<void> {
|
|
74
|
+
if (running) return;
|
|
75
|
+
running = true;
|
|
76
|
+
const startedFor = generation();
|
|
77
|
+
say(copyParam(`${options.prefix}.restoringText`, "Restoring…"), startedFor);
|
|
78
|
+
options.repaint();
|
|
79
|
+
// Stamped with the session the tap was for: an answer landing after a sign-out is never shown.
|
|
80
|
+
say(restoreMessage(await restored(commerce), options.prefix), startedFor);
|
|
81
|
+
running = false;
|
|
82
|
+
options.repaint();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
fields: (sellsThroughApple) => ({
|
|
87
|
+
restoreHidden: !sellsThroughApple,
|
|
88
|
+
restoreLabel: copyParam(`${options.prefix}.restorePurchasesCta`, "Restore purchases"),
|
|
89
|
+
restoreDisabled: running,
|
|
90
|
+
// The answer was for the person signed in when it ran; another session never inherits it.
|
|
91
|
+
restoreMessage: sellsThroughApple && messageGeneration === generation() ? message : "",
|
|
92
|
+
}),
|
|
93
|
+
run,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function restored(commerce: Pick<IapCommerce, "restore">): Promise<IapRestoreResult> {
|
|
98
|
+
return commerce.restore().catch(() => ({ status: "failed", code: "storeUnavailable" }) as const);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function restoreMessage(result: IapRestoreResult, prefix: string): string {
|
|
102
|
+
if (result.status === "needsSignIn") return copyParam(`${prefix}.restoreSignInText`, "Sign in to restore your purchases.");
|
|
103
|
+
if (result.status === "failed") return result.code === "sessionChanged" ? "" : copyParam(`${prefix}.restoreFailedText`, "Couldn't reach the App Store. Try again.");
|
|
104
|
+
// Every part that happened is said: a recovery never hides a refusal or a purchase still pending.
|
|
105
|
+
const parts = [
|
|
106
|
+
result.recovered === 1 ? copyParam(`${prefix}.restoredOneText`, "Restored 1 purchase.") : "",
|
|
107
|
+
result.recovered > 1 ? copyTemplate(`${prefix}.restoredManyText`, "Restored {count} purchases.", { count: result.recovered }) : "",
|
|
108
|
+
// Held unfinished because Wix refused it (another member's purchase, say).
|
|
109
|
+
result.refused > 0 ? copyParam(`${prefix}.restoreRefusedText`, "A purchase on this Apple ID couldn't be added to this account. Contact support.") : "",
|
|
110
|
+
result.pending > 0 ? copyParam(`${prefix}.restorePendingText`, "A purchase is still being confirmed. Try again in a moment.") : "",
|
|
111
|
+
].filter(Boolean);
|
|
112
|
+
return parts.length ? parts.join(" ") : copyParam(`${prefix}.restoreUpToDateText`, "Your purchases are up to date.");
|
|
113
|
+
}
|
|
114
|
+
|
|
34
115
|
/** What `notHydrated` does, for a runtime that ignores the option (a binary older than it, still
|
|
35
116
|
* served new bundles): put the listed keys back to their declared values before any action runs,
|
|
36
117
|
* so a persisted price or an enabled purchase action never paints as this run's. */
|