@wix/himalaya-cli 0.1614.0 → 0.1615.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 (106) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -2,12 +2,15 @@
2
2
  //
3
3
  // Published Gallery page photos are carried by site analyze in the descriptor and
4
4
  // displayed without a Pro Gallery request. Sites with no page gallery use the Wix
5
- // API gateway. Failed reads remain errors; sample data is explicit preview only.
5
+ // API gateway: the list takes each gallery's first item as its cover, and opening a
6
+ // gateway gallery reads its items. Failed reads remain errors; sample data is explicit
7
+ // preview only.
6
8
  //
7
9
  import { defineService } from "../_shared/collection-service.js";
8
10
  import { get as getAppInfo } from "@himalaya/app-info";
9
11
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
10
- import { presence, type PresenceFlags } from "../_shared/detail-presence.js";
12
+ import { isNotFoundError, presence, type PresenceFlags } from "../_shared/detail-presence.js";
13
+ import { wixMediaUrl } from "@himalaya/wix-media";
11
14
 
12
15
  const GALLERIES_PATH = "/progallery/v2/galleries";
13
16
  const LEGACY_GALLERIES_URL =
@@ -20,9 +23,12 @@ function wixApi(): WixApi | null {
20
23
  return (globalThis as { himalaya?: { wixApi?: WixApi } }).himalaya?.wixApi ?? null;
21
24
  }
22
25
 
23
- interface RawGallery { id: string; name?: string; totalItems?: number; totalItemsCount?: number }
26
+ interface RawItem { id?: unknown; title?: unknown; image?: { imageInfo?: unknown } }
27
+ interface RawGallery { id: string; name?: string; totalItems?: number; totalItemsCount?: number; items?: RawItem[]; source?: "gateway" | "legacy" }
24
28
  interface StaticGallery { id: string; title: string; photos: string[] }
25
- interface Row { id: string; title: string; subtitle: string; name: string; description: string; image: string; noCover: boolean }
29
+ /** `source`: which route listed the gallery. Only a GATEWAY gallery can be opened live by id —
30
+ * a legacy row's id (or a synthesized `gallery-<n>`) is unknown to the gateway. */
31
+ interface Row { id: string; title: string; subtitle: string; name: string; description: string; image: string; noCover: boolean; hasCover: boolean; source: "gateway" | "legacy" | "page" }
26
32
  interface PhotoRow { id: string; url: string; title: string }
27
33
  interface S extends LoadFlags, PresenceFlags {
28
34
  galleries: Row[]; emptyText: string;
@@ -56,12 +62,56 @@ function parseStaticGalleries(raw: unknown): StaticGallery[] {
56
62
  }
57
63
 
58
64
  let staticGalleries: StaticGallery[] = [];
65
+ /** The gateway gallery open on screen: each `open` counts up, and a read answers for its own. */
66
+ let openSeq = 0;
67
+ type PhotosRead = { seq: number; title?: string; photos?: PhotoRow[] } | { seq: number; error: unknown };
68
+
69
+ /** A Pro Gallery image item's URL. `imageInfo` is either a Wix media object (`{ id, url }`,
70
+ * where `url` may be the bare media id) or a `wix:image://` string; anything else is "". */
71
+ function itemUrl(item: RawItem | undefined): string {
72
+ const info = item?.image?.imageInfo;
73
+ if (typeof info === "string") return wixMediaUrl(info);
74
+ if (!info || typeof info !== "object") return "";
75
+ const media = info as { url?: unknown; id?: unknown };
76
+ return wixMediaUrl(media.url) || wixMediaUrl(media.id);
77
+ }
59
78
 
60
79
  function norm(g: RawGallery, i: number): Row {
61
80
  const title = (g.name || "").trim() || `Gallery ${i + 1}`;
62
81
  const n = g.totalItems ?? g.totalItemsCount ?? 0;
63
82
  const subtitle = n ? (n === 1 ? "1 photo" : `${n} photos`) : "";
64
- return { id: g.id, title, subtitle, name: title, description: subtitle, image: "", noCover: true };
83
+ // The list asks for one item per gallery (itemLimit=1): that item is the cover.
84
+ const image = itemUrl(Array.isArray(g.items) ? g.items[0] : undefined);
85
+ return { id: g.id, title, subtitle, name: title, description: subtitle, image, noCover: !image, hasCover: !!image, source: g.source ?? "gateway" };
86
+ }
87
+
88
+ /** A gateway gallery's photos, in the gallery's own order, page by page (`itemOffset` /
89
+ * `itemLimit`; `totalItems` says when to stop — `offset`/`limit` are ignored on this route).
90
+ * Capped so a huge gallery cannot stall the screen. Video and text items carry no image and
91
+ * are skipped. */
92
+ const ITEM_PAGE = 100, ITEM_MAX = 500;
93
+ async function readGalleryPhotos(api: WixApi, id: string): Promise<{ title: string; photos: PhotoRow[] }> {
94
+ let name: unknown = "";
95
+ const items: RawItem[] = [];
96
+ for (let offset = 0; offset < ITEM_MAX; offset += ITEM_PAGE) {
97
+ const res = await api.call("GET", `${GALLERIES_PATH}/${encodeURIComponent(id)}?itemOffset=${offset}&itemLimit=${ITEM_PAGE}`) as { gallery?: { name?: unknown; items?: unknown; totalItems?: unknown } } | null;
98
+ const gallery = res?.gallery;
99
+ if (!gallery || typeof gallery !== "object") throw new Error("Malformed Pro Gallery response");
100
+ if (offset === 0) name = gallery.name;
101
+ const page = Array.isArray(gallery.items) ? gallery.items as RawItem[] : [];
102
+ items.push(...page);
103
+ const total = typeof gallery.totalItems === "number" ? gallery.totalItems : undefined;
104
+ if (page.length < ITEM_PAGE || (total !== undefined && items.length >= total)) break;
105
+ }
106
+ const photos = items.flatMap((item, index): PhotoRow[] => {
107
+ // The original media URL, like a page gallery's: the photo viewer (`view`) accepts only
108
+ // that form, and the renderer sizes the image to its slot.
109
+ const url = itemUrl(item).replace(/\/v1\/.*$/, "");
110
+ if (!url) return [];
111
+ const title = typeof item.title === "string" && item.title.trim() ? item.title.trim() : `Photo ${index + 1}`;
112
+ return [{ id: typeof item.id === "string" && item.id ? item.id : `${id}-${index}`, url, title }];
113
+ });
114
+ return { title: typeof name === "string" ? name.trim() : "", photos };
65
115
  }
66
116
 
67
117
  async function readGalleries(api: WixApi): Promise<RawGallery[]> {
@@ -88,7 +138,7 @@ async function readLegacyGalleries(api: WixApi): Promise<RawGallery[]> {
88
138
  return raw.map((gallery, index) => {
89
139
  if (!gallery || typeof gallery !== "object") throw new Error("Malformed legacy Pro Gallery row");
90
140
  const row = gallery as Partial<RawGallery>;
91
- return { ...row, id: typeof row.id === "string" && row.id ? row.id : `gallery-${index}` };
141
+ return { ...row, id: typeof row.id === "string" && row.id ? row.id : `gallery-${index}`, source: "legacy" as const };
92
142
  });
93
143
  }
94
144
 
@@ -118,9 +168,9 @@ async function readCompatibleGalleries(api: WixApi): Promise<RawGallery[]> {
118
168
  }
119
169
 
120
170
  const MOCK: Row[] = [
121
- { id: "sample-work", title: "Our Work", subtitle: "24 photos", name: "Our Work", description: "24 photos", image: "", noCover: true },
122
- { id: "sample-events", title: "Events", subtitle: "48 photos", name: "Events", description: "48 photos", image: "", noCover: true },
123
- { id: "sample-team", title: "The Team", subtitle: "9 photos", name: "The Team", description: "9 photos", image: "", noCover: true },
171
+ { id: "sample-work", title: "Our Work", subtitle: "24 photos", name: "Our Work", description: "24 photos", image: "", noCover: true, hasCover: false, source: "page" },
172
+ { id: "sample-events", title: "Events", subtitle: "48 photos", name: "Events", description: "48 photos", image: "", noCover: true, hasCover: false, source: "page" },
173
+ { id: "sample-team", title: "The Team", subtitle: "9 photos", name: "The Team", description: "9 photos", image: "", noCover: true, hasCover: false, source: "page" },
124
174
  ];
125
175
 
126
176
  /** sample rows reach the screen ONLY in the host's explicit sample preview;
@@ -133,14 +183,21 @@ const COPY: LoadCopy<Row> = {
133
183
  sample: () => MOCK.map((r) => ({ ...r })),
134
184
  };
135
185
 
186
+ /** The newest state, for an action that answers after an await (`s` is from before it). */
187
+ const current = (s: S): S => (svc.getState?.() as S | undefined) ?? s;
188
+
136
189
  const svc = defineService<S, {
137
190
  load: (s: S, arg?: { staticGalleries?: unknown }) => Promise<S>;
138
191
  open: (s: S, arg?: { staticGalleries?: unknown }) => S;
192
+ photosRead: (s: S, arg?: PhotosRead) => S | undefined;
139
193
  view: (s: S) => S;
140
194
  }>("himiBrandedGalleries", {
141
195
  state: { ...LOADING, ...presence("loading"), galleries: [], emptyText: "", galleryTitle: "", photos: [], photoUrl: "", photoTitle: "", noGallery: true, hasPhotos: false, hasPhoto: false, noPhoto: true },
142
196
  actions: {
143
197
  load: async (s, arg) => {
198
+ // No `openSeq` bump here: the list and a gallery never share a worker (workers are pooled by
199
+ // screen and input), and a host's refresh runs `load` on the gallery's own worker, where a
200
+ // bump would drop that gallery's read in flight and leave its screen blank.
144
201
  try {
145
202
  if (arg && "staticGalleries" in arg) {
146
203
  staticGalleries = [];
@@ -149,7 +206,7 @@ const svc = defineService<S, {
149
206
  if (staticGalleries.length) {
150
207
  const rows = staticGalleries.map((gallery) => {
151
208
  const subtitle = gallery.photos.length === 1 ? "1 photo" : `${gallery.photos.length} photos`;
152
- return { id: gallery.id, title: gallery.title, subtitle, name: gallery.title, description: subtitle, image: gallery.photos[0], noCover: false };
209
+ return { id: gallery.id, title: gallery.title, subtitle, name: gallery.title, description: subtitle, image: gallery.photos[0], noCover: false, hasCover: true, source: "page" as const };
153
210
  });
154
211
  return { ...s, ...loaded("galleries", rows, COPY) };
155
212
  }
@@ -157,12 +214,17 @@ const svc = defineService<S, {
157
214
  if (!api) return { ...s, ...loadFailed("galleries", new Error("Wix bridge unavailable"), COPY) };
158
215
  const raw = await readCompatibleGalleries(api);
159
216
  const galleries = raw.map(norm);
160
- return { ...s, ...loaded("galleries", galleries, COPY) };
217
+ // From the state as it is NOW: on a gallery's own worker, its photos may have landed
218
+ // (`photosRead`) during the read, and `s` is from before it.
219
+ return { ...current(s), ...loaded("galleries", galleries, COPY) };
161
220
  } catch (err) {
162
- return { ...s, ...loadFailed("galleries", err, COPY) };
221
+ return { ...current(s), ...loadFailed("galleries", err, COPY) };
163
222
  }
164
223
  },
165
224
  open: (s, arg) => {
225
+ // Every open, live or not, makes the reads of earlier opens stale (`photosRead` drops them):
226
+ // a page or legacy gallery opened after a slow gateway read must not get that read's photos.
227
+ const seq = ++openSeq;
166
228
  try {
167
229
  if (arg && "staticGalleries" in arg) {
168
230
  staticGalleries = [];
@@ -174,11 +236,41 @@ const svc = defineService<S, {
174
236
  }
175
237
  const id = input("id");
176
238
  const gallery = staticGalleries.find((entry) => entry.id === id);
239
+ // No page galleries were carried in: a gateway (Pro Gallery app) gallery is read live, but
240
+ // ONLY when the tap says the list row came from the gateway (`source: "gateway"`, the row's
241
+ // own field). A legacy row's id and a sample row's id ("sample-work") are unknown to the
242
+ // gateway, and a tap that passes no source (Branded Lite's) cannot tell them apart: those
243
+ // open as before, by their title.
244
+ const api = wixApi();
245
+ const live = input("source") === "gateway" && !/^gallery-\d+$/.test(id);
246
+ if (id && !staticGalleries.length && api && live) {
247
+ // The read lands through `photosRead`, tagged with this open: a pooled worker shows THIS
248
+ // gallery loading at once (never the last one's photos), and a slower read for a gallery
249
+ // left since is dropped instead of overwriting the one on screen.
250
+ const name = input("name");
251
+ void readGalleryPhotos(api, id).then(
252
+ ({ title, photos }) => svc.photosRead({ seq, title: title || name, photos }),
253
+ (err: unknown) => svc.photosRead({ seq, error: err }),
254
+ );
255
+ return { ...s, ...LOADING, ...presence("loading"), galleryTitle: name, photos: [], noGallery: true, hasPhotos: false,
256
+ photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
257
+ }
177
258
  const missing = !id || (staticGalleries.length > 0 && !gallery);
178
259
  return { ...s, ...loaded("galleries", s.galleries, COPY), ...presence(missing ? "missing" : "found"), galleryTitle: gallery?.title || input("name"),
179
260
  photos: (gallery?.photos ?? []).map((url, index) => ({ id: `${id}-${index}`, url, title: `Photo ${index + 1}` })),
180
261
  noGallery: missing, hasPhotos: !!gallery?.photos.length, noPhoto: true };
181
262
  },
263
+ photosRead: (s, arg) => {
264
+ if (!arg || arg.seq !== openSeq) return undefined;
265
+ if ("error" in arg) {
266
+ // A 404 is the gallery being GONE — the not-found state alone, never also an error card
267
+ // whose Retry cannot succeed. Any other failure is the error state.
268
+ return { ...s, ...(isNotFoundError(arg.error) ? { ...loaded("galleries", s.galleries, COPY), ...presence("missing") } : { ...loadFailed("galleries", arg.error, COPY), ...presence("failed") }),
269
+ galleryTitle: "", photos: [], noGallery: true, hasPhotos: false, photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
270
+ }
271
+ return { ...s, ...loaded("galleries", s.galleries, COPY), ...presence("found"), galleryTitle: arg.title ?? "",
272
+ photos: arg.photos ?? [], noGallery: false, hasPhotos: (arg.photos ?? []).length > 0, photoUrl: "", photoTitle: "", hasPhoto: false, noPhoto: true };
273
+ },
182
274
  view: (s) => {
183
275
  const photoUrl = input("url");
184
276
  const valid = /^https:\/\/static\.wixstatic\.com\/media\/[A-Za-z0-9_~.-]+$/.test(photoUrl);
@@ -35,11 +35,14 @@ export interface LoadFlags {
35
35
  notMock: boolean;
36
36
  notMockEmpty: boolean;
37
37
  notMockUnavailable: boolean;
38
+ /** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
39
+ * (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
40
+ hideEmpty: boolean;
38
41
  }
39
42
 
40
43
  export const LOADING: LoadFlags = {
41
44
  status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
42
- isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
45
+ isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
43
46
  };
44
47
 
45
48
  const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
78
81
  export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
79
82
  if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
80
83
  if (samplesRequested()) return sampleRows(key, copy, "empty");
81
- return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
84
+ return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
82
85
  }
83
86
 
84
87
  /** A query threw. Never rows the customer did not create. */
@@ -1,6 +1,8 @@
1
1
  import { dispatch } from "@himalaya/dispatch";
2
2
  // Relative for node resolution (see himi-store.ts note); rewritten off-repo.
3
3
  import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
4
+ import { getMyMember } from "@himalaya/wix-members";
5
+ import { wixMediaUrl } from "@himalaya/wix-media";
4
6
  import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
5
7
 
6
8
  export interface AccountProfile {
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
82
84
  };
83
85
  }
84
86
 
87
+ /** The signed-in site member's name and photo from their member record, or undefined when it
88
+ * cannot be read or names nobody (the caller then shows the email). */
89
+ async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
90
+ try {
91
+ const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
92
+ const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
93
+ || (member?.profile?.nickname ?? "").trim();
94
+ if (!name) return undefined;
95
+ const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
96
+ return {
97
+ isMember: true,
98
+ identityLine: "Signed in",
99
+ detailLine: email || name,
100
+ memberName: name,
101
+ memberEmail: email || member?.loginEmail || "",
102
+ memberImage: image,
103
+ memberInitials: initialsFromName(name) || "M",
104
+ hasMemberImage: !!image,
105
+ noMemberImage: !image,
106
+ };
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
85
112
  export function guestProfile(detailLine: string): AccountProfile {
86
113
  return {
87
114
  isMember: false,
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
118
145
  const data = await getMyData();
119
146
  return profileFromData(data, storedEmail);
120
147
  } catch {
148
+ // The account profile is an owner endpoint: the web host refuses it, and a site member need
149
+ // not have one. The member's own record (the Members API) still names them.
150
+ const member = await siteMemberProfile(storedEmail);
151
+ if (member) return member;
121
152
  return {
122
153
  isMember: true,
123
154
  identityLine: "Signed in",
@@ -35,11 +35,14 @@ export interface LoadFlags {
35
35
  notMock: boolean;
36
36
  notMockEmpty: boolean;
37
37
  notMockUnavailable: boolean;
38
+ /** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
39
+ * (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
40
+ hideEmpty: boolean;
38
41
  }
39
42
 
40
43
  export const LOADING: LoadFlags = {
41
44
  status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
42
- isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
45
+ isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
43
46
  };
44
47
 
45
48
  const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
78
81
  export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
79
82
  if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
80
83
  if (samplesRequested()) return sampleRows(key, copy, "empty");
81
- return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
84
+ return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
82
85
  }
83
86
 
84
87
  /** A query threw. Never rows the customer did not create. */
@@ -1,6 +1,8 @@
1
1
  import { dispatch } from "@himalaya/dispatch";
2
2
  // Relative for node resolution (see himi-store.ts note); rewritten off-repo.
3
3
  import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
4
+ import { getMyMember } from "@himalaya/wix-members";
5
+ import { wixMediaUrl } from "@himalaya/wix-media";
4
6
  import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
5
7
 
6
8
  export interface AccountProfile {
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
82
84
  };
83
85
  }
84
86
 
87
+ /** The signed-in site member's name and photo from their member record, or undefined when it
88
+ * cannot be read or names nobody (the caller then shows the email). */
89
+ async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
90
+ try {
91
+ const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
92
+ const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
93
+ || (member?.profile?.nickname ?? "").trim();
94
+ if (!name) return undefined;
95
+ const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
96
+ return {
97
+ isMember: true,
98
+ identityLine: "Signed in",
99
+ detailLine: email || name,
100
+ memberName: name,
101
+ memberEmail: email || member?.loginEmail || "",
102
+ memberImage: image,
103
+ memberInitials: initialsFromName(name) || "M",
104
+ hasMemberImage: !!image,
105
+ noMemberImage: !image,
106
+ };
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
85
112
  export function guestProfile(detailLine: string): AccountProfile {
86
113
  return {
87
114
  isMember: false,
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
118
145
  const data = await getMyData();
119
146
  return profileFromData(data, storedEmail);
120
147
  } catch {
148
+ // The account profile is an owner endpoint: the web host refuses it, and a site member need
149
+ // not have one. The member's own record (the Members API) still names them.
150
+ const member = await siteMemberProfile(storedEmail);
151
+ if (member) return member;
121
152
  return {
122
153
  isMember: true,
123
154
  identityLine: "Signed in",
@@ -35,11 +35,14 @@ export interface LoadFlags {
35
35
  notMock: boolean;
36
36
  notMockEmpty: boolean;
37
37
  notMockUnavailable: boolean;
38
+ /** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
39
+ * (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
40
+ hideEmpty: boolean;
38
41
  }
39
42
 
40
43
  export const LOADING: LoadFlags = {
41
44
  status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
42
- isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
45
+ isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
43
46
  };
44
47
 
45
48
  const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
78
81
  export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
79
82
  if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
80
83
  if (samplesRequested()) return sampleRows(key, copy, "empty");
81
- return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
84
+ return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
82
85
  }
83
86
 
84
87
  /** A query threw. Never rows the customer did not create. */
@@ -1,6 +1,8 @@
1
1
  import { dispatch } from "@himalaya/dispatch";
2
2
  // Relative for node resolution (see himi-store.ts note); rewritten off-repo.
3
3
  import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
4
+ import { getMyMember } from "@himalaya/wix-members";
5
+ import { wixMediaUrl } from "@himalaya/wix-media";
4
6
  import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
5
7
 
6
8
  export interface AccountProfile {
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
82
84
  };
83
85
  }
84
86
 
87
+ /** The signed-in site member's name and photo from their member record, or undefined when it
88
+ * cannot be read or names nobody (the caller then shows the email). */
89
+ async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
90
+ try {
91
+ const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
92
+ const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
93
+ || (member?.profile?.nickname ?? "").trim();
94
+ if (!name) return undefined;
95
+ const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
96
+ return {
97
+ isMember: true,
98
+ identityLine: "Signed in",
99
+ detailLine: email || name,
100
+ memberName: name,
101
+ memberEmail: email || member?.loginEmail || "",
102
+ memberImage: image,
103
+ memberInitials: initialsFromName(name) || "M",
104
+ hasMemberImage: !!image,
105
+ noMemberImage: !image,
106
+ };
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
85
112
  export function guestProfile(detailLine: string): AccountProfile {
86
113
  return {
87
114
  isMember: false,
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
118
145
  const data = await getMyData();
119
146
  return profileFromData(data, storedEmail);
120
147
  } catch {
148
+ // The account profile is an owner endpoint: the web host refuses it, and a site member need
149
+ // not have one. The member's own record (the Members API) still names them.
150
+ const member = await siteMemberProfile(storedEmail);
151
+ if (member) return member;
121
152
  return {
122
153
  isMember: true,
123
154
  identityLine: "Signed in",
@@ -0,0 +1,88 @@
1
+ // Saved (favorite) booking services use the host's durable, app-scoped KV, shared by every
2
+ // screen worker — the service detail toggles a row (himi-booking-service `toggleSaved`), the
3
+ // Favorites list reads them (himi-booking-saved). Same model as _shared/himi-store-saved.ts: a
4
+ // failed write must never claim that a save succeeded.
5
+ export interface SavedService {
6
+ id: string;
7
+ name: string;
8
+ /** The price line as the detail showed it ("$250"), kept so the list paints offline. */
9
+ price: string;
10
+ /** The duration line ("1 hr"). */
11
+ duration: string;
12
+ image: string;
13
+ category: string;
14
+ /** A course: the list's Book passes `course` to the time screen, which then refuses a course
15
+ * whose record failed to load instead of offering its sessions one by one. */
16
+ course?: boolean;
17
+ }
18
+ type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
19
+ type Host = { himalaya?: { storage?: StorageLike; input?: Record<string, unknown> }; localStorage?: StorageLike };
20
+ const BASE = "himi-booking:saved-services:v1";
21
+ function storage(): StorageLike {
22
+ const host = globalThis as Host;
23
+ const kv = host.himalaya?.storage ?? host.localStorage;
24
+ if (!kv) throw new Error("Saved-service storage is unavailable");
25
+ return kv;
26
+ }
27
+ const validPart = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && value !== "default";
28
+ /** Saves belong to whoever made them. A signed-in member's live under the host's authenticated
29
+ * account + business — the partition collection preferences use, so a second member on the same
30
+ * device never sees them; a visitor's live on the device. A member's partition, read for the
31
+ * first time, takes the visitor's saves on this device (signing in carries them, like a cart),
32
+ * once — the device list is then emptied. */
33
+ function storeKey(kv: StorageLike): string {
34
+ const input = (globalThis as Host).himalaya?.input;
35
+ const account = input?.__himiPreferenceAccount, business = input?.__himiPreferenceBusiness;
36
+ if (!validPart(account) || !validPart(business)) return BASE;
37
+ const key = `${BASE}.${account}.${encodeURIComponent(business)}`;
38
+ if (kv.getItem(key) === null) {
39
+ const visitor = kv.getItem(BASE);
40
+ if (visitor !== null) {
41
+ kv.setItem(key, visitor);
42
+ // Empty the device list only once the member's copy is confirmed: a web host may swallow a
43
+ // failed write, and clearing then would lose the visitor's saves.
44
+ if (kv.getItem(key) === visitor) kv.setItem(BASE, "[]");
45
+ }
46
+ }
47
+ return key;
48
+ }
49
+ const str = (value: unknown): string => (typeof value === "string" ? value : "");
50
+ export function readSavedServices(): SavedService[] {
51
+ const kv = storage();
52
+ const raw = kv.getItem(storeKey(kv));
53
+ if (!raw) return [];
54
+ const parsed: unknown = JSON.parse(raw);
55
+ if (!Array.isArray(parsed)) throw new Error("Invalid saved-service data");
56
+ const rows = new Map<string, SavedService>();
57
+ for (const value of parsed) {
58
+ if (!value || typeof value !== "object") continue;
59
+ const row = value as Record<string, unknown>;
60
+ if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
61
+ rows.set(row.id, { id: row.id, name: row.name, price: str(row.price), duration: str(row.duration), image: str(row.image), category: str(row.category), ...(row.course === true ? { course: true } : {}) });
62
+ }
63
+ return [...rows.values()];
64
+ }
65
+
66
+ export function isServiceSaved(id: string): boolean {
67
+ return readSavedServices().some(row => row.id === id);
68
+ }
69
+ export function writeSavedServices(rows: SavedService[]): void {
70
+ const kv = storage();
71
+ const key = storeKey(kv);
72
+ const value = JSON.stringify(rows);
73
+ kv.setItem(key, value);
74
+ // Web hosts may swallow quota/security errors. Confirm the synchronous write
75
+ // before displaying success; a missing/mismatched read is a failed save.
76
+ if (kv.getItem(key) !== value) throw new Error("Saved-service write did not persist");
77
+ }
78
+ /** Save or unsave; returns the new saved state. The newest save is listed first. */
79
+ export function toggleSavedService(service: SavedService): boolean {
80
+ if (!service.id.trim() || !service.name.trim()) throw new Error("No service to save");
81
+ const rows = readSavedServices();
82
+ const saved = !rows.some(row => row.id === service.id);
83
+ writeSavedServices(saved ? [service, ...rows] : rows.filter(row => row.id !== service.id));
84
+ return saved;
85
+ }
86
+ export function removeSavedService(id: string): void {
87
+ writeSavedServices(readSavedServices().filter(row => row.id !== id));
88
+ }
@@ -22,6 +22,7 @@ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-med
22
22
  import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
23
23
  import { descriptionBody } from "../_shared/rich-body.js";
24
24
  import { openRichLink } from "../_shared/rich-content-link.js";
25
+ import { isServiceSaved, toggleSavedService } from "../_shared/himi-booking-saved.js";
25
26
 
26
27
  // Offline-first (data-offline): the service detail is a public read-through. On a
27
28
  // fetch failure we fall back to the last-synced row for this service written by
@@ -182,6 +183,13 @@ interface S {
182
183
  sessionMessageOk: boolean;
183
184
  sessionMessageFailed: boolean;
184
185
  noSessionMessage: boolean;
186
+ // ---- Favorites (the heart an app adds with withServiceSaveToggle; _shared/himi-booking-saved.ts) ----
187
+ saved: boolean;
188
+ notSaved: boolean;
189
+ /** The heart's spoken label: what a tap does now. */
190
+ saveLabel: string;
191
+ /** "" unless the last save or unsave failed. */
192
+ saveMessage: string;
185
193
  }
186
194
 
187
195
  function inputId(): string {
@@ -790,6 +798,10 @@ function derive(s: S): S {
790
798
  // Change Time opens the time screen, which cannot book a service closed to online booking.
791
799
  noChangeTime: (control !== "book" && control !== "waitlist" && control !== "closed") || !s.bookableOnline,
792
800
  noSessionMessage: !s.sessionMessage,
801
+ notSaved: !s.saved,
802
+ saveLabel: s.saved
803
+ ? copyParam("service.unsaveLabel", "Remove from favorites")
804
+ : copyParam("service.saveLabel", "Save to favorites"),
793
805
  notBookableOnline: !s.bookableOnline,
794
806
  noAbout: !s.about && !s.aboutJson,
795
807
  noPolicy: s.policyLines.length === 0,
@@ -866,9 +878,16 @@ function withService(s: S, service: WixService): S {
866
878
  // WS-C — canonical param-less target; the descriptor's choose-time CTA
867
879
  // binds the separate `id` state field as a first-class nav param.
868
880
  timeScreenId: "himi_time",
881
+ saved: savedNow(serviceId),
869
882
  });
870
883
  }
871
884
 
885
+ /** Whether the service is a favorite. Unreadable storage reads as not saved: the heart is a
886
+ * convenience and never holds the screen back. */
887
+ function savedNow(id: string): boolean {
888
+ try { return !!id && isServiceSaved(id); } catch { return false; }
889
+ }
890
+
872
891
  /** The service record: with staff details when this token may read them, else without. */
873
892
  async function fetchService(id: string): Promise<WixService | undefined> {
874
893
  try {
@@ -897,9 +916,10 @@ const svc = defineService<S, {
897
916
  book: (s: S) => S;
898
917
  changeTime: (s: S) => S;
899
918
  joinWaitlist: (s: S, arg?: unknown) => Promise<S>;
919
+ toggleSaved: (s: S) => S;
900
920
  }>("himiBookingService", {
901
921
  // A join's outcome belongs to that visit: a cold start must not replay it from saved state.
902
- notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage"],
922
+ notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage", "saveMessage"],
903
923
  state: derive({
904
924
  status: "loading",
905
925
  id,
@@ -1005,6 +1025,10 @@ const svc = defineService<S, {
1005
1025
  noChangeTime: true,
1006
1026
  ...NO_SESSION_MESSAGE,
1007
1027
  noSessionMessage: true,
1028
+ saved: false,
1029
+ notSaved: true,
1030
+ saveLabel: "",
1031
+ saveMessage: "",
1008
1032
  }),
1009
1033
  actions: {
1010
1034
  load: async (s, arg) => {
@@ -1012,14 +1036,16 @@ const svc = defineService<S, {
1012
1036
  rememberSignIn(arg);
1013
1037
  if (!s.id) return derive({ ...s, status: "ready", errored: true });
1014
1038
  // A new view: a join's outcome from before is not news when the screen shows again.
1015
- const start = { ...s, policyExpanded: false, scheduleExpanded: false, ...NO_SESSION_MESSAGE };
1039
+ const start = { ...s, policyExpanded: false, scheduleExpanded: false, saveMessage: "", ...NO_SESSION_MESSAGE };
1016
1040
  try {
1017
1041
  const service = await fetchService(s.id);
1018
1042
  if (!service) return withSession(derive({ ...start, status: "ready" }), undefined);
1019
1043
  loadedService = service;
1020
1044
  // The session the Agenda row opened on, read beside the service-wide availability.
1021
1045
  const [shown, sel] = await Promise.all([withGroupAvailability(withService(start, service), service), readSessionBounded(service)]);
1022
- return withSession(shown, sel);
1046
+ // The heart is read again after the waits (the session read takes up to 10 s): a toggle
1047
+ // tapped meanwhile wrote storage, and the snapshot taken before would undo it.
1048
+ return withSession({ ...shown, saved: savedNow(s.id) }, sel);
1023
1049
  } catch {
1024
1050
  // Offline: paint from the last-synced services cache (written by the list
1025
1051
  // worker) before falling back to the error state. The cache holds the name,
@@ -1038,6 +1064,7 @@ const svc = defineService<S, {
1038
1064
  category: String(cached.category ?? ""),
1039
1065
  images: image ? [{ url: image, alt: name }] : [],
1040
1066
  timeScreenId: `himi_time_${s.id}`,
1067
+ saved: savedNow(s.id),
1041
1068
  }, undefined);
1042
1069
  }
1043
1070
  return withSession({ ...start, status: "ready", errored: true, name: "" }, undefined);
@@ -1056,6 +1083,26 @@ const svc = defineService<S, {
1056
1083
  },
1057
1084
  togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
1058
1085
  toggleSchedule: (s) => derive({ ...s, scheduleExpanded: !s.scheduleExpanded }),
1086
+ // The heart: save or unsave this service on the device (the member's own list once signed
1087
+ // in). The row keeps what the Favorites list shows offline; a failed write says so and never
1088
+ // shows the heart as saved.
1089
+ toggleSaved: (s) => {
1090
+ if (!s.id || !s.hasService || s.errored) return s;
1091
+ try {
1092
+ const saved = toggleSavedService({
1093
+ id: s.id,
1094
+ name: s.name,
1095
+ price: s.priceIsText ? "" : s.priceText,
1096
+ duration: s.durationText,
1097
+ image: s.image,
1098
+ category: s.category,
1099
+ ...(s.isCourse ? { course: true } : {}),
1100
+ });
1101
+ return derive({ ...s, saved, saveMessage: "" });
1102
+ } catch {
1103
+ return derive({ ...s, saved: savedNow(s.id), saveMessage: copyParam("service.saveError", "Could not update your favorites. Try again.") });
1104
+ }
1105
+ },
1059
1106
  // The footer button opens the time screen for the loaded service. A worker directive,
1060
1107
  // not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
1061
1108
  // dispatch their taps without resolving `$service:` params, so the id never arrived.