@wix/himalaya-cli 0.1210.0 → 0.1212.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 (118) hide show
  1. package/dist/cli.mjs +91 -1
  2. package/package.json +2 -2
  3. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +57 -16
  4. package/templates/beauty-salon-owner/dev/index.ts +280 -37
  5. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +31 -16
  6. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +57 -16
  7. package/templates/beauty-salon-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  8. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  9. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  13. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +57 -16
  14. package/templates/community-club/tier5-src/_shared/engine-intl.ts +57 -16
  15. package/templates/community-club-owner/dev/index.ts +389 -37
  16. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +31 -16
  17. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +57 -16
  18. package/templates/community-club-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  19. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  20. package/templates/community-club-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  21. package/templates/community-club-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  22. package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  23. package/templates/community-club-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  24. package/templates/community-club-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  25. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +57 -16
  26. package/templates/events-meetups-owner/dev/index.ts +280 -37
  27. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +31 -16
  28. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +57 -16
  29. package/templates/events-meetups-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  30. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  31. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  32. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  33. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  34. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  35. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +57 -16
  36. package/templates/gym-fitness-owner/dev/index.ts +389 -37
  37. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +31 -16
  38. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +57 -16
  39. package/templates/gym-fitness-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  40. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  41. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  42. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  43. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  44. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  45. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  46. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +57 -16
  47. package/templates/nonprofit-cause-owner/dev/index.ts +280 -37
  48. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +31 -16
  49. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +57 -16
  50. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  51. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  52. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  53. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  54. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  55. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  56. package/templates/owner-lite/dev/index.ts +389 -37
  57. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +31 -16
  58. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +57 -16
  59. package/templates/owner-lite/tier5-src/_shared/owner-module-data.ts +105 -37
  60. package/templates/owner-lite/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  61. package/templates/owner-lite/tier5-src/himi-owner-lite-event/index.ts +59 -14
  62. package/templates/owner-lite/tier5-src/himi-owner-lite-faq/index.ts +8 -2
  63. package/templates/owner-lite/tier5-src/himi-owner-lite-home/index.ts +5 -0
  64. package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  65. package/templates/owner-lite/tier5-src/himi-owner-lite-post/index.ts +63 -16
  66. package/templates/owner-lite/tier5-src/himi-owner-lite-product/index.ts +61 -16
  67. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +57 -16
  68. package/templates/podcast-creator-owner/dev/index.ts +389 -37
  69. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +31 -16
  70. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +57 -16
  71. package/templates/podcast-creator-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  72. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  73. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  74. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  75. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  76. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  77. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  78. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +57 -16
  79. package/templates/portfolio-creative-owner/dev/index.ts +280 -37
  80. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +31 -16
  81. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +57 -16
  82. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  83. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  84. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  85. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  86. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  87. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  88. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +57 -16
  89. package/templates/restaurant-owner/dev/index.ts +389 -37
  90. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +31 -16
  91. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +57 -16
  92. package/templates/restaurant-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  93. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  94. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  95. package/templates/restaurant-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  96. package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  97. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  98. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  99. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +57 -16
  100. package/templates/retail-store-owner/dev/index.ts +280 -37
  101. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +31 -16
  102. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +57 -16
  103. package/templates/retail-store-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  104. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  105. package/templates/retail-store-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  106. package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  107. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
  108. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product/index.ts +61 -16
  109. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +57 -16
  110. package/templates/yoga-studio-owner/dev/index.ts +280 -37
  111. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +31 -16
  112. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +57 -16
  113. package/templates/yoga-studio-owner/tier5-src/_shared/owner-module-data.ts +105 -37
  114. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria-history/index.ts +1 -0
  115. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event/index.ts +59 -14
  116. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-home/index.ts +5 -0
  117. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +4 -2
  118. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post/index.ts +63 -16
@@ -13,9 +13,11 @@ import { queryGiftCards, getGiftCard } from '@himalaya/wix-gift-cards';
13
13
  import { queryDonationCampaigns, getDonationCampaign } from '@himalaya/wix-donations';
14
14
  import { query as queryBadges, get as getBadge } from '@himalaya/wix-badges';
15
15
  import { wixMediaUrl } from '@himalaya/wix-media';
16
+ import { getSiteTimeZone } from '@himalaya/wix-analytics';
16
17
  import { type OwnerModuleId } from './owner-modules.js';
17
18
  import { type CollectionPage } from './collection-query.js';
18
- import { copyParam } from './himi-copy.js';
19
+ import { copyParam, copyTemplate } from './himi-copy.js';
20
+ import { calendarDay, formatDateMedium, formatMonthDay, formatWeekdayDateTime } from './engine-intl.js';
19
21
  import { eligibleOwnerOperations } from './owner-operations.js';
20
22
 
21
23
  export interface OwnerRecord {
@@ -33,91 +35,144 @@ const join = (...values: string[]) => values.filter(Boolean).join(' · ');
33
35
  const name = (value: unknown, first: string, last: string) => [field(value, first), field(value, last)].filter(Boolean).join(' ');
34
36
  const yesNo = (value: unknown) => typeof value === 'boolean' ? value ? 'Yes' : 'No' : '';
35
37
  const LIMIT = 50;
38
+
39
+ /** The site's IANA zone, resolved once per worker. Wix hands every date here as a UTC instant;
40
+ * formatting it in the viewer's device zone (or leaving it raw) shows an owner outside UTC the
41
+ * wrong hour and sometimes the wrong day. A failed or unset lookup degrades to the
42
+ * device zone — a zone is garnish on a date, never a reason to fail the collection. The
43
+ * answer, failure included, is kept for the worker's life: a role that cannot read site
44
+ * properties would otherwise re-send the failing request for every page a search walks. */
45
+ let zoneLookup: Promise<string | undefined> | undefined;
46
+ function siteZone(): Promise<string | undefined> {
47
+ // `Promise.resolve().then` so a bridge that throws synchronously is caught here too.
48
+ zoneLookup ??= Promise.resolve().then(getSiteTimeZone).catch(() => undefined);
49
+ return zoneLookup;
50
+ }
51
+
52
+ /** A Wix date value as an instant. A calendar date with no time ("2026-11-07") is a DAY, not an
53
+ * instant: it is built at local midnight and formatted with no zone, so it never shifts across
54
+ * one — including on engines without Intl, where a zone could not be applied anyway. */
55
+ function ownerInstant(value: string): { date: Date; dayOnly: boolean } | undefined {
56
+ if (!value) return undefined;
57
+ const day = /^(\d{4})-(\d\d)-(\d\d)$/.exec(value);
58
+ if (day) return { date: new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])), dayOnly: true };
59
+ const ms = /^\d{13}$/.test(value) ? Number(value) : Date.parse(value);
60
+ return Number.isFinite(ms) ? { date: new Date(ms), dayOnly: false } : undefined;
61
+ }
62
+ /** Has the instant (or, for a calendar date, the whole day) passed? */
63
+ function ownerPast(value: string, now = Date.now()): boolean {
64
+ const parsed = ownerInstant(value);
65
+ if (!parsed) return false;
66
+ const { date, dayOnly } = parsed;
67
+ return (dayOnly ? new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime() : date.getTime()) <= now;
68
+ }
69
+
70
+ type DateStyle = 'date' | 'dateTime' | 'short';
71
+ /** A Wix date as owner copy, through the shared engine-intl shapes: `date` "Jan 5, 2026",
72
+ * `dateTime` "Mon, Jan 5, 3:07 PM", `short` "Jan 5" (with the year when it is not this year).
73
+ * A value that is not a date at all is kept as Wix sent it. */
74
+ function ownerDate(value: string, zone: string | undefined, style: DateStyle = 'date'): string {
75
+ if (!value) return '';
76
+ const parsed = ownerInstant(value);
77
+ if (!parsed) return value;
78
+ const { date } = parsed, tz = parsed.dayOnly ? undefined : zone;
79
+ if (style === 'dateTime') return formatWeekdayDateTime(date, undefined, tz);
80
+ if (style === 'short' && calendarDay(date, tz).slice(0, 4) === calendarDay(new Date(), tz).slice(0, 4)) return formatMonthDay(date, undefined, tz);
81
+ return formatDateMedium(date, undefined, tz);
82
+ }
36
83
  function offsetOf(cursor: string): number {
37
84
  if (cursor && !/^\d{1,9}$/.test(cursor)) throw new Error('Invalid collection cursor. Refresh this view.');
38
85
  return Number(cursor || 0);
39
86
  }
40
- function page(module: OwnerModuleId, rows: unknown[], nextCursor: string, total?: number, complete?: boolean): CollectionPage<OwnerRecord> {
41
- return { items: rows.map(row => ownerRecord(module, row)), nextCursor, total, ...(complete === undefined ? {} : { complete }) };
87
+ function page(rows: unknown[], nextCursor: string, total?: number, complete?: boolean): CollectionPage<unknown> {
88
+ return { items: rows, nextCursor, total, ...(complete === undefined ? {} : { complete }) };
42
89
  }
43
- function offsetPage(module: OwnerModuleId, rows: unknown[], offset: number, metadata: unknown) {
90
+ function offsetPage(rows: unknown[], offset: number, metadata: unknown) {
44
91
  const total = at(metadata, 'total');
45
92
  const more = rows.length > 0 && (typeof total === 'number' ? offset + rows.length < total : rows.length === LIMIT);
46
- return page(module, rows, more ? String(offset + rows.length) : '', typeof total === 'number' ? total : undefined);
93
+ return page(rows, more ? String(offset + rows.length) : '', typeof total === 'number' ? total : undefined);
47
94
  }
48
95
 
49
96
  /** Domain query shapes stay here, shared by list and detail workers. Unsupported
50
97
  * server name queries remain explicitly labelled searches of loaded records. */
51
98
  export async function queryOwnerModule(module: OwnerModuleId, cursor = ''): Promise<CollectionPage<OwnerRecord>> {
99
+ // The zone lookup runs beside the query, not after it: the two are independent, and serial
100
+ // they would double the time to a first paint.
101
+ const [raw, zone] = await Promise.all([queryRawOwnerModule(module, cursor), siteZone()]);
102
+ return { ...raw, items: raw.items.map(row => ownerRecord(module, row, zone)) };
103
+ }
104
+
105
+ async function queryRawOwnerModule(module: OwnerModuleId, cursor: string): Promise<CollectionPage<unknown>> {
52
106
  const cursorPaging = { limit: LIMIT, ...(cursor ? { cursor } : {}) };
53
107
  switch (module) {
54
108
  case 'invoices': {
55
109
  const offset = offsetOf(cursor), r = await queryInvoices({ query: { paging: { limit: LIMIT, offset } } });
56
- return offsetPage(module, r.invoices ?? [], offset, r.pagingMetadata);
110
+ return offsetPage(r.invoices ?? [], offset, r.pagingMetadata);
57
111
  }
58
112
  case 'coupons': {
59
113
  const offset = offsetOf(cursor), r = await queryCoupons({ paging: { limit: LIMIT, offset } });
60
- return offsetPage(module, r.coupons ?? [], offset, r.metadata);
114
+ return offsetPage(r.coupons ?? [], offset, r.metadata);
61
115
  }
62
116
  case 'giftCards': {
63
117
  const r = await queryGiftCards({ query: { cursorPaging, sort: [{ fieldName: 'createdDate', order: 'DESC' }] } });
64
- return page(module, r.giftCards ?? [], r.pagingMetadata?.cursors?.next ?? '');
118
+ return page(r.giftCards ?? [], r.pagingMetadata?.cursors?.next ?? '');
65
119
  }
66
120
  case 'contacts': {
67
121
  const offset = offsetOf(cursor), r = await queryContacts({ query: { paging: { limit: LIMIT, offset } } });
68
- return offsetPage(module, r.contacts ?? [], offset, r.pagingMetadata);
122
+ return offsetPage(r.contacts ?? [], offset, r.pagingMetadata);
69
123
  }
70
124
  case 'members': {
71
125
  const offset = offsetOf(cursor), r = await queryMembers({ query: { paging: { limit: LIMIT, offset } } });
72
- return offsetPage(module, r.members ?? [], offset, r.metadata);
126
+ return offsetPage(r.members ?? [], offset, r.metadata);
73
127
  }
74
128
  case 'groups': {
75
129
  const offset = offsetOf(cursor), r = await queryGroups({ paging: { limit: LIMIT, offset } });
76
- return offsetPage(module, r.groups ?? [], offset, r.metaData);
130
+ return offsetPage(r.groups ?? [], offset, r.metaData);
77
131
  }
78
132
  case 'reviews': {
79
133
  const r = await queryReviews({ cursorPaging, sort: [{ fieldName: 'createdDate', order: 'DESC' }] }, true);
80
- return page(module, r.reviews ?? r.results ?? [], r.pagingMetadata?.cursors?.next ?? '');
134
+ return page(r.reviews ?? r.results ?? [], r.pagingMetadata?.cursors?.next ?? '');
81
135
  }
82
136
  case 'programs': {
83
137
  const r = await queryPrograms({ cursorPaging });
84
- return page(module, r.programs ?? [], r.pagingMetadata?.cursors?.next ?? '');
138
+ return page(r.programs ?? [], r.pagingMetadata?.cursors?.next ?? '');
85
139
  }
86
140
  case 'badges': {
87
141
  const r = await queryBadges({ query: { cursorPaging } });
88
- return page(module, r.badges ?? [], r.pagingMetadata?.cursors?.next ?? '');
142
+ return page(r.badges ?? [], r.pagingMetadata?.cursors?.next ?? '');
89
143
  }
90
144
  case 'reservations': {
91
145
  const r = await queryReservations({ cursorPaging, sort: [{ fieldName: 'details.startDate', order: 'ASC' }] });
92
- return page(module, r.reservations ?? [], r.pagingMetadata?.cursors?.next ?? '');
146
+ return page(r.reservations ?? [], r.pagingMetadata?.cursors?.next ?? '');
93
147
  }
94
148
  case 'portfolio': {
95
149
  const r = await listProjects(LIMIT, cursor || undefined);
96
- return page(module, r.projects ?? [], r.pagingMetadata?.cursors?.next ?? '');
150
+ return page(r.projects ?? [], r.pagingMetadata?.cursors?.next ?? '');
97
151
  }
98
152
  case 'donationCampaigns': {
99
153
  const r = await queryDonationCampaigns({ query: { cursorPaging, sort: [{ fieldName: 'updatedDate', order: 'DESC' }] } });
100
- return page(module, r.donationCampaigns ?? [], r.pagingMetadata?.cursors?.next ?? '');
154
+ return page(r.donationCampaigns ?? [], r.pagingMetadata?.cursors?.next ?? '');
101
155
  }
102
156
  case 'forms': {
103
157
  // This collection is Wix Forms. Booking/event registration forms belong
104
158
  // to their respective operational records, rather than a mixed union
105
159
  // that silently drops namespaces when one permission check fails.
106
160
  const offset = offsetOf(cursor), r = await queryForms({ filter: { namespace: 'wix.form_app.form' }, paging: { limit: LIMIT, offset } });
107
- return offsetPage(module, r.forms ?? [], offset, r.metaData);
161
+ return offsetPage(r.forms ?? [], offset, r.metaData);
108
162
  }
109
163
  case 'plans': {
110
164
  const r = await listPlans();
111
- return page(module, r.plans ?? [], '', r.pagingMetadata?.total, false);
165
+ return page(r.plans ?? [], '', r.pagingMetadata?.total, false);
112
166
  }
113
167
  case 'subscriptions': {
114
168
  const r = await listPlanOrders();
115
- return page(module, r.orders ?? [], '', r.pagingMetadata?.total, false);
169
+ return page(r.orders ?? [], '', r.pagingMetadata?.total, false);
116
170
  }
117
171
  }
118
172
  }
119
173
 
120
- export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
174
+ export function ownerRecord(module: OwnerModuleId, raw: unknown, zone?: string): OwnerRecord {
175
+ const when = (path: string, style?: DateStyle) => ownerDate(field(raw, path), zone, style);
121
176
  const id = field(raw, 'id');
122
177
  if (!id) throw new Error('Wix returned a record without an identity. Refresh this view.');
123
178
  let title = '', subtitle = '', image = '', statusLabel = '';
@@ -127,7 +182,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
127
182
  title = field(raw, 'specification.name') || field(raw, 'displayData.name');
128
183
  subtitle = field(raw, 'specification.code');
129
184
  statusLabel = at(raw, 'expired') === true ? 'Expired' : at(raw, 'specification.active') === true ? 'Active' : 'Inactive';
130
- details = [['Code', subtitle], ['Uses', field(raw, 'numberOfUsages')], ['Offer', words(field(raw, 'specification.type'))], ['Expires', field(raw, 'specification.expirationTime')]];
185
+ details = [['Code', subtitle], ['Uses', field(raw, 'numberOfUsages')], ['Offer', words(field(raw, 'specification.type'))], ['Expires', when('specification.expirationTime')]];
131
186
  break;
132
187
  case 'giftCards': {
133
188
  const suffix = field(raw, 'codeSuffix');
@@ -147,7 +202,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
147
202
  ['Initial value', field(raw, 'initialValue.formattedAmount') || join(field(raw, 'initialValue.amount'), field(raw, 'currency'))],
148
203
  ['Recipient', recipient],
149
204
  ['Order number', field(raw, 'orderInfo.orderNumber')],
150
- ['Expires', expires],
205
+ ['Expires', when('expirationDate')],
151
206
  ['Source', words(field(raw, 'source'))],
152
207
  ];
153
208
  break;
@@ -156,7 +211,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
156
211
  title = field(raw, 'number') ? `#${field(raw, 'number')}` : field(raw, 'title');
157
212
  subtitle = field(raw, 'customerInfo.contactDetails.fullName') || name(raw, 'customerInfo.contactDetails.firstName', 'customerInfo.contactDetails.lastName') || field(raw, 'customerInfo.contactDetails.email');
158
213
  statusLabel = words(field(raw, 'status'));
159
- details = [['Customer', subtitle], ['Total', join(field(raw, 'totals.total'), field(raw, 'currency'))], ['Due', field(raw, 'dueDate')]];
214
+ details = [['Customer', subtitle], ['Total', join(field(raw, 'totals.total'), field(raw, 'currency'))], ['Due', when('dueDate')]];
160
215
  break;
161
216
  case 'contacts':
162
217
  title = name(raw, 'info.name.first', 'info.name.last') || field(raw, 'primaryInfo.email');
@@ -167,12 +222,12 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
167
222
  case 'members':
168
223
  title = field(raw, 'profile.nickname') || name(raw, 'contact.firstName', 'contact.lastName') || field(raw, 'loginEmail');
169
224
  subtitle = field(raw, 'loginEmail'); image = field(raw, 'profile.photo.url'); statusLabel = words(field(raw, 'status'));
170
- details = [['Email', subtitle], ['Last login', field(raw, 'lastLoginDate')]];
225
+ details = [['Email', subtitle], ['Last login', when('lastLoginDate')]];
171
226
  break;
172
227
  case 'groups':
173
228
  title = field(raw, 'name'); subtitle = field(raw, 'description') || field(raw, 'teaser');
174
229
  image = field(raw, 'coverImage.imageInfo.url'); statusLabel = words(field(raw, 'privacyStatus'));
175
- details = [['Members', field(raw, 'memberCount') || field(raw, 'membersCount')], ['Last activity', field(raw, 'lastActivityDate')]];
230
+ details = [['Members', field(raw, 'memberCount') || field(raw, 'membersCount')], ['Last activity', when('lastActivityDate')]];
176
231
  break;
177
232
  case 'reviews':
178
233
  title = field(raw, 'content.title'); subtitle = field(raw, 'content.body'); statusLabel = words(field(raw, 'moderation.moderationStatus'));
@@ -184,8 +239,14 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
184
239
  details = [['Price', join(field(raw, 'pricing.price.value'), field(raw, 'pricing.price.currency'))], ['Free trial days', field(raw, 'pricing.freeTrialDays')]];
185
240
  break;
186
241
  case 'subscriptions':
187
- title = field(raw, 'planName'); subtitle = field(raw, 'buyer.memberId'); statusLabel = words(field(raw, 'status'));
188
- details = [['Member ID', subtitle], ['Started', field(raw, 'startDate')], ['Ends', field(raw, 'endDate')], ['Payment', words(field(raw, 'lastPaymentStatus'))]];
242
+ // The buyer is only a member UUID here — not something an owner can read — so the
243
+ // row says when the plan started instead of echoing an internal id.
244
+ title = field(raw, 'planName'); statusLabel = words(field(raw, 'status'));
245
+ {
246
+ const started = when('startDate', 'short');
247
+ subtitle = started ? copyTemplate('ownerModules.startedOn', 'Started {date}', { date: started }) : '';
248
+ }
249
+ details = [['Started', when('startDate')], ['Ends', when('endDate')], ['Payment', words(field(raw, 'lastPaymentStatus'))]];
189
250
  break;
190
251
  case 'programs':
191
252
  title = field(raw, 'description.title') || field(raw, 'details.title') || field(raw, 'name');
@@ -203,13 +264,14 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
203
264
  break;
204
265
  }
205
266
  case 'reservations':
206
- title = name(raw, 'reservee.firstName', 'reservee.lastName'); subtitle = field(raw, 'details.startDate'); statusLabel = words(field(raw, 'status'));
267
+ title = name(raw, 'reservee.firstName', 'reservee.lastName'); subtitle = when('details.startDate', 'dateTime'); statusLabel = words(field(raw, 'status'));
207
268
  details = [['Arrival', subtitle], ['Party size', field(raw, 'details.partySize')], ['Email', field(raw, 'reservee.email')], ['Phone', field(raw, 'reservee.phone')]];
208
269
  break;
209
270
  case 'forms':
210
- title = field(raw, 'name'); subtitle = field(raw, 'namespace');
271
+ // The list is filtered to one namespace (`wix.form_app.form`), so the namespace is an
272
+ // internal constant, not information — it never reaches copy.
273
+ title = field(raw, 'name') || copyParam('ownerModules.untitledForm', 'Untitled form');
211
274
  statusLabel = typeof at(raw, 'enabled') === 'boolean' ? at(raw, 'enabled') ? 'Enabled' : 'Disabled' : '';
212
- details = [['Namespace', subtitle]];
213
275
  break;
214
276
  case 'portfolio':
215
277
  title = field(raw, 'title'); subtitle = field(raw, 'description'); image = portfolioCoverRef(at(raw, 'coverImage') as WixMediaImage | string | undefined);
@@ -217,20 +279,25 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
217
279
  break;
218
280
  case 'donationCampaigns':
219
281
  title = field(raw, 'name');
220
- subtitle = join(field(raw, 'campaignGoal.targetAmount.formattedAmount'), field(raw, 'campaignGoal.endDate'));
282
+ {
283
+ const end = when('campaignGoal.endDate', 'short');
284
+ subtitle = join(field(raw, 'campaignGoal.targetAmount.formattedAmount'), !end ? ''
285
+ : ownerPast(field(raw, 'campaignGoal.endDate')) ? copyTemplate('ownerModules.endedOn', 'ended {date}', { date: end })
286
+ : copyTemplate('ownerModules.endsOn', 'ends {date}', { date: end }));
287
+ }
221
288
  image = field(raw, 'coverImage');
222
289
  statusLabel = at(raw, 'archived') === true ? 'Archived' : words(field(raw, 'status'));
223
290
  details = [
224
291
  ['Goal', field(raw, 'campaignGoal.targetAmount.formattedAmount') || field(raw, 'campaignGoal.targetAmount.amount')],
225
- ['End date', field(raw, 'campaignGoal.endDate')],
292
+ ['End date', when('campaignGoal.endDate')],
226
293
  ['Frequencies', Array.isArray(at(raw, 'donationFrequencies')) ? (at(raw, 'donationFrequencies') as unknown[]).map(String).join(', ') : ''],
227
294
  ['Custom amount', yesNo(at(raw, 'customAmountEnabled'))],
228
295
  ];
229
296
  break;
230
297
  }
231
298
  details.push(
232
- ['Created', field(raw, 'createdDate') || field(raw, 'dateCreated') || field(raw, 'createDate')],
233
- ['Updated', field(raw, 'updatedDate') || field(raw, 'updateDate')],
299
+ ['Created', when('createdDate') || when('dateCreated') || when('createDate')],
300
+ ['Updated', when('updatedDate') || when('updateDate')],
234
301
  );
235
302
  return { id, moduleId: module, title: title || copyParam('ownerModules.untitled', 'Untitled record'), subtitle, image: wixMediaUrl(image), statusLabel,
236
303
  detailScreenId: `himi_owner_lite_record_${module}`,
@@ -240,6 +307,7 @@ export function ownerRecord(module: OwnerModuleId, raw: unknown): OwnerRecord {
240
307
 
241
308
  export async function getOwnerRecord(module: OwnerModuleId, id: string): Promise<OwnerRecord> {
242
309
  if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) throw new Error('This record link is invalid. Return to the collection.');
310
+ const zone = siteZone(); // started now, beside the record fetch; it never rejects
243
311
  let raw: unknown;
244
312
  switch (module) {
245
313
  case 'coupons': raw = (await getCoupon(id)).coupon; break;
@@ -270,13 +338,13 @@ export async function getOwnerRecord(module: OwnerModuleId, id: string): Promise
270
338
  seen.add(next); cursor = next;
271
339
  }
272
340
  if (!raw) throw new Error('This project could not be located. Open the portfolio in Wix to check its availability.');
273
- const record = ownerRecord(module, raw);
341
+ const record = ownerRecord(module, raw, await zone);
274
342
  const media = await listProjectItems(id, 100);
275
343
  return { ...record, fields: [...record.fields, { id: 'media', label: 'Loaded media items', value: String(media.items?.length ?? 0) }] };
276
344
  }
277
345
  case 'donationCampaigns': raw = (await getDonationCampaign(id)).donationCampaign; break;
278
346
  }
279
- const record = ownerRecord(module, raw);
347
+ const record = ownerRecord(module, raw, await zone);
280
348
  if (record.id !== id) throw new Error('The returned record does not match this link. Refresh the collection.');
281
349
  return record;
282
350
  }
@@ -26,6 +26,7 @@ const core = createAriaHistory({
26
26
  last7: () => copyParam("ownerAriaHistory.last7", "Last 7 days"),
27
27
  last30: () => copyParam("ownerAriaHistory.last30", "Last 30 days"),
28
28
  older: () => copyParam("ownerAriaHistory.older", "Older"),
29
+ today: () => copyParam("ownerAriaHistory.today", "Today"),
29
30
  },
30
31
  });
31
32
 
@@ -62,6 +62,9 @@ interface EventState {
62
62
  /** Non-empty when showing owner-preview-demo sample data (web preview, no owner bridge). */
63
63
  demoNote: string;
64
64
  notDemo: boolean;
65
+ /** #3158 — Delete ARMS a confirmation; only confirmDelete reaches the API. */
66
+ deletePending: boolean;
67
+ noDeleteConfirmation: boolean;
65
68
  }
66
69
 
67
70
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -139,6 +142,9 @@ function shape(s: Partial<EventState>): EventState {
139
142
  noCover: !image,
140
143
  demoNote: s.demoNote ?? "",
141
144
  notDemo: !s.demoNote,
145
+ // Only a loaded entity can be pending deletion; every other action disarms it (`disarm`).
146
+ deletePending: !!s.deletePending && hasEntity,
147
+ noDeleteConfirmation: !(s.deletePending && hasEntity),
142
148
  };
143
149
  }
144
150
 
@@ -224,15 +230,32 @@ async function refresh(prev: EventState, id: string): Promise<EventState> {
224
230
  });
225
231
  }
226
232
 
233
+ /** #3158 — a confirmed delete in flight. Synchronous and module-level on purpose: two
234
+ * confirmDelete dispatches (a double tap) both start from a state that says "armed", and only a
235
+ * flag set BEFORE the first await stops the second from issuing its own DELETE — whose late 404
236
+ * would otherwise land after, and overwrite, the first one's "deleted". */
237
+ let deleteInFlight = false;
238
+
239
+ /** #3158 — the confirmation describes THIS visit. Never take it from a persisted snapshot, or a
240
+ * reopened record comes back with "Delete this …?" already armed. */
241
+ const DELETE_RUN_KEYS = ["deletePending", "noDeleteConfirmation"] as const;
242
+
243
+ /** #3158 — clear an armed delete confirmation. */
244
+ const disarm = <T extends { deletePending: boolean }>(s: T): T => ({ ...s, deletePending: false });
245
+
227
246
  const svc = defineService<EventState, {
228
247
  load: (s: EventState, arg?: unknown) => Promise<EventState>;
229
248
  retry: (s: EventState) => Promise<EventState>;
230
249
  delete: (s: EventState) => Promise<EventState>;
250
+ cancelDelete: (s: EventState) => Promise<EventState>;
251
+ confirmDelete: (s: EventState) => Promise<EventState | undefined>;
231
252
  openLink: (s: EventState, arg?: unknown) => Promise<EventState>;
232
253
  }>("himiOwnerLiteEvent", {
233
254
  state: shape({ status: "loading" }),
255
+ notHydrated: [...DELETE_RUN_KEYS],
234
256
  actions: {
235
- load: async (s, arg) => {
257
+ load: async (s0, arg) => {
258
+ const s = disarm(s0);
236
259
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
237
260
  const id = idFrom(arg, s.id) || inputId();
238
261
  if (!id) return shape({ ...s, status: "error", error: copyParam("ownerEvent.errorNoId", "no event id") });
@@ -257,7 +280,8 @@ const svc = defineService<EventState, {
257
280
  return shape({ ...s, id, status: "error", error: ownerLoadError(e) });
258
281
  }
259
282
  },
260
- retry: async (s) => {
283
+ retry: async (s0) => {
284
+ const s = disarm(s0);
261
285
  if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerEvent.errorNoId", "no event id") });
262
286
  if (isOwnerPreviewDemo()) {
263
287
  const demo = demoDetail(s, s.id);
@@ -279,20 +303,35 @@ const svc = defineService<EventState, {
279
303
  return shape({ ...s, status: "error", error: ownerLoadError(e) });
280
304
  }
281
305
  },
282
- delete: async (s) => {
283
- if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
284
- if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerEvent.noIdMessage", "No event id.") });
285
- // Optimistic local delete — the list drops the row immediately.
286
- await cacheDrop(s.id);
306
+ // #3158 — destructive, so two steps. `delete` (the Manage card's Delete button)
307
+ // only ARMS the inline confirmation, which names the record and says it cannot
308
+ // be undone; `cancelDelete` disarms it; only `confirmDelete` deletes. Anything
309
+ // that dispatches `delete` directly therefore still asks first.
310
+ delete: async (s) => shape({ ...s, deletePending: true, actionMessage: "" }),
311
+ cancelDelete: async (s) => shape({ ...s, deletePending: false }),
312
+ confirmDelete: async (s0) => {
313
+ // Nothing armed (a stray dispatch, a rehydrated snapshot) or a delete already running
314
+ // (a second tap): ignore it — `undefined` leaves the state exactly as it is.
315
+ if (!s0.deletePending || deleteInFlight) return undefined;
316
+ deleteInFlight = true;
287
317
  try {
288
- await deleteEvent(s.id);
289
- return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerEvent.deletedEmpty", "Event deleted."), actionMessage: "" });
290
- } catch (e) {
291
- if (shouldQueueOwnerWrite(e)) {
292
- await queueWrite("tier5.himiOwnerLiteEvent.delete", { id: s.id });
293
- return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerEvent.deletedEmpty", "Event deleted."), actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
318
+ const s = disarm(s0);
319
+ if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
320
+ if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerEvent.noIdMessage", "No event id.") });
321
+ // Optimistic local delete — the list drops the row immediately.
322
+ await cacheDrop(s.id);
323
+ try {
324
+ await deleteEvent(s.id);
325
+ return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerEvent.deletedEmpty", "Event deleted."), actionMessage: "" });
326
+ } catch (e) {
327
+ if (shouldQueueOwnerWrite(e)) {
328
+ await queueWrite("tier5.himiOwnerLiteEvent.delete", { id: s.id });
329
+ return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerEvent.deletedEmpty", "Event deleted."), actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
330
+ }
331
+ return shape({ ...s, actionMessage: ownerLoadError(e) });
294
332
  }
295
- return shape({ ...s, actionMessage: ownerLoadError(e) });
333
+ } finally {
334
+ deleteInFlight = false;
296
335
  }
297
336
  },
298
337
  // [rico] #3499 — a link tapped in the RichContent description.
@@ -303,4 +342,10 @@ const svc = defineService<EventState, {
303
342
  },
304
343
  });
305
344
 
345
+ // The same reset on a runtime from before `notHydrated` (an older binary on a new bundle).
346
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
347
+ const booted = svc.getState?.() as unknown as Record<string, unknown> | undefined;
348
+ if (booted && typeof booted === "object") Object.assign(booted, { deletePending: false, noDeleteConfirmation: true });
349
+ }
350
+
306
351
  export { svc };
@@ -84,6 +84,10 @@ interface HomeState {
84
84
  notLoading: boolean;
85
85
  notErrored: boolean;
86
86
  hideContent: boolean;
87
+ /** The retry cards' list is hidden unless the dashboard is ready AND a source failed.
88
+ * An empty list still costs its parent stack a gap, which on iOS read as a blank
89
+ * band under the header; gating on `hideContent` alone left it mounted. */
90
+ hideSourceFailures: boolean;
87
91
  demoNote: string;
88
92
  notDemo: boolean;
89
93
  }
@@ -145,6 +149,7 @@ function shape(s: Partial<HomeState>): HomeState {
145
149
  notLoading: !isLoading,
146
150
  notErrored: status !== "error",
147
151
  hideContent: status !== "ready",
152
+ hideSourceFailures: status !== "ready" || !(s.sourceFailures?.length),
148
153
  demoNote: s.demoNote ?? "",
149
154
  notDemo: !s.demoNote,
150
155
  };
@@ -39,6 +39,8 @@ interface State {
39
39
  noEmpty: boolean;
40
40
  hasRecents: boolean;
41
41
  noRecents: boolean;
42
+ /** True while `sections` is empty — gates the list so an empty one costs no gap. */
43
+ noSections: boolean;
42
44
  isLoading: boolean;
43
45
  notLoading: boolean;
44
46
  summaryNote: string;
@@ -200,7 +202,7 @@ function derive(state: State, areas = state.areas): State {
200
202
  // areas arrive — or when this app provisions none at all — there is nothing a
201
203
  // filter could have excluded, so "Clear filters" would be a lie.
202
204
  const noEmpty = next.noEmpty || state.status !== 'ready' || areas.length === 0;
203
- return { ...state, ...next, noEmpty, recents, hasRecents: recents.length > 0, noRecents: recents.length === 0, businessName: currentBusinessName };
205
+ return { ...state, ...next, noEmpty, recents, hasRecents: recents.length > 0, noRecents: recents.length === 0, noSections: next.sections.length === 0, businessName: currentBusinessName };
204
206
  }
205
207
 
206
208
  // Loads are not serialized: on a warm reappearance the host's bare refresh and
@@ -276,7 +278,7 @@ const svc = defineService<State, {
276
278
  // 'All' is always a valid filter, so the chip row paints from the first frame
277
279
  // instead of appearing (and shifting the layout) when the areas arrive.
278
280
  groups: [{ id: 'all', label: allLabel() }], summary: '', noEmpty: true,
279
- hasRecents: false, noRecents: true, isLoading: true, notLoading: false, summaryNote: '', handoffError: '',
281
+ hasRecents: false, noRecents: true, noSections: true, isLoading: true, notLoading: false, summaryNote: '', handoffError: '',
280
282
  },
281
283
  actions: {
282
284
  load: async (state, arg) => {
@@ -80,6 +80,9 @@ interface PostState {
80
80
  /** Non-empty when showing owner-preview-demo sample data (web preview, no owner bridge). */
81
81
  demoNote: string;
82
82
  notDemo: boolean;
83
+ /** #3158 — Delete ARMS a confirmation; only confirmDelete reaches the API. */
84
+ deletePending: boolean;
85
+ noDeleteConfirmation: boolean;
83
86
  }
84
87
 
85
88
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -159,6 +162,9 @@ function shape(s: Partial<PostState>): PostState {
159
162
  hideUnpublish: !published || status !== "ready",
160
163
  demoNote: s.demoNote ?? "",
161
164
  notDemo: !s.demoNote,
165
+ // Only a loaded entity can be pending deletion; every other action disarms it (`disarm`).
166
+ deletePending: !!s.deletePending && hasEntity,
167
+ noDeleteConfirmation: !(s.deletePending && hasEntity),
162
168
  };
163
169
  }
164
170
 
@@ -276,18 +282,35 @@ async function refresh(prev: PostState, id: string, actionMessage = ""): Promise
276
282
  });
277
283
  }
278
284
 
285
+ /** #3158 — a confirmed delete in flight. Synchronous and module-level on purpose: two
286
+ * confirmDelete dispatches (a double tap) both start from a state that says "armed", and only a
287
+ * flag set BEFORE the first await stops the second from issuing its own DELETE — whose late 404
288
+ * would otherwise land after, and overwrite, the first one's "deleted". */
289
+ let deleteInFlight = false;
290
+
291
+ /** #3158 — the confirmation describes THIS visit. Never take it from a persisted snapshot, or a
292
+ * reopened record comes back with "Delete this …?" already armed. */
293
+ const DELETE_RUN_KEYS = ["deletePending", "noDeleteConfirmation"] as const;
294
+
295
+ /** #3158 — clear an armed delete confirmation. */
296
+ const disarm = <T extends { deletePending: boolean }>(s: T): T => ({ ...s, deletePending: false });
297
+
279
298
  const svc = defineService<PostState, {
280
299
  load: (s: PostState, arg?: unknown) => Promise<PostState>;
281
300
  retry: (s: PostState) => Promise<PostState>;
282
301
  publish: (s: PostState) => Promise<PostState>;
283
302
  unpublish: (s: PostState) => Promise<PostState>;
284
303
  delete: (s: PostState) => Promise<PostState>;
304
+ cancelDelete: (s: PostState) => Promise<PostState>;
305
+ confirmDelete: (s: PostState) => Promise<PostState | undefined>;
285
306
  /** [rico] #3370 — RichContent's on_link: `{ url }` of the tapped link. */
286
307
  openLink: (s: PostState, arg?: unknown) => Promise<PostState>;
287
308
  }>("himiOwnerLitePost", {
288
309
  state: shape({ status: "loading" }),
310
+ notHydrated: [...DELETE_RUN_KEYS],
289
311
  actions: {
290
- load: async (s, arg) => {
312
+ load: async (s0, arg) => {
313
+ const s = disarm(s0);
291
314
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
292
315
  const id = idFrom(arg, s.id) || inputId();
293
316
  if (!id) return shape({ ...s, status: "error", error: copyParam("ownerPost.errorNoId", "no post id") });
@@ -312,7 +335,8 @@ const svc = defineService<PostState, {
312
335
  return shape({ ...s, id, status: "error", error: ownerLoadError(e) });
313
336
  }
314
337
  },
315
- retry: async (s) => {
338
+ retry: async (s0) => {
339
+ const s = disarm(s0);
316
340
  if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerPost.errorNoId", "no post id") });
317
341
  if (isOwnerPreviewDemo()) {
318
342
  const demo = demoDetail(s, s.id);
@@ -334,7 +358,8 @@ const svc = defineService<PostState, {
334
358
  return shape({ ...s, status: "error", error: ownerLoadError(e) });
335
359
  }
336
360
  },
337
- publish: async (s) => {
361
+ publish: async (s0) => {
362
+ const s = disarm(s0);
338
363
  if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
339
364
  if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerPost.noIdMessage", "No post id.") });
340
365
  if (s.postStatusRaw === "PUBLISHED") return shape({ ...s, actionMessage: copyParam("ownerPost.alreadyPublished", "Post is already published.") });
@@ -352,7 +377,8 @@ const svc = defineService<PostState, {
352
377
  return shape({ ...s, actionMessage: ownerLoadError(e) });
353
378
  }
354
379
  },
355
- unpublish: async (s) => {
380
+ unpublish: async (s0) => {
381
+ const s = disarm(s0);
356
382
  if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
357
383
  if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerPost.noIdMessage", "No post id.") });
358
384
  if (s.postStatusRaw !== "PUBLISHED" && s.postStatusRaw !== "SCHEDULED") {
@@ -372,20 +398,35 @@ const svc = defineService<PostState, {
372
398
  return shape({ ...s, actionMessage: ownerLoadError(e) });
373
399
  }
374
400
  },
375
- delete: async (s) => {
376
- if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
377
- if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerPost.noIdMessage", "No post id.") });
378
- // Optimistic local delete — the list drops the row immediately.
379
- await cacheDrop(s.id);
401
+ // #3158 — destructive, so two steps. `delete` (the Manage card's Delete button)
402
+ // only ARMS the inline confirmation, which names the record and says it cannot
403
+ // be undone; `cancelDelete` disarms it; only `confirmDelete` deletes. Anything
404
+ // that dispatches `delete` directly therefore still asks first.
405
+ delete: async (s) => shape({ ...s, deletePending: true, actionMessage: "" }),
406
+ cancelDelete: async (s) => shape({ ...s, deletePending: false }),
407
+ confirmDelete: async (s0) => {
408
+ // Nothing armed (a stray dispatch, a rehydrated snapshot) or a delete already running
409
+ // (a second tap): ignore it — `undefined` leaves the state exactly as it is.
410
+ if (!s0.deletePending || deleteInFlight) return undefined;
411
+ deleteInFlight = true;
380
412
  try {
381
- await deleteDraftPost(s.id);
382
- return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerPost.deletedEmpty", "Post deleted."), actionMessage: "" });
383
- } catch (e) {
384
- if (shouldQueueOwnerWrite(e)) {
385
- await queueWrite("tier5.himiOwnerLitePost.delete", { id: s.id });
386
- return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerPost.deletedEmpty", "Post deleted."), actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
413
+ const s = disarm(s0);
414
+ if (isOwnerPreviewDemo() || s.demoNote) return shape({ ...s, actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
415
+ if (!s.id) return shape({ ...s, actionMessage: copyParam("ownerPost.noIdMessage", "No post id.") });
416
+ // Optimistic local delete — the list drops the row immediately.
417
+ await cacheDrop(s.id);
418
+ try {
419
+ await deleteDraftPost(s.id);
420
+ return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerPost.deletedEmpty", "Post deleted."), actionMessage: "" });
421
+ } catch (e) {
422
+ if (shouldQueueOwnerWrite(e)) {
423
+ await queueWrite("tier5.himiOwnerLitePost.delete", { id: s.id });
424
+ return shape({ ...s, status: "deleted", title: "", emptyText: copyParam("ownerPost.deletedEmpty", "Post deleted."), actionMessage: copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) });
425
+ }
426
+ return shape({ ...s, actionMessage: ownerLoadError(e) });
387
427
  }
388
- return shape({ ...s, actionMessage: ownerLoadError(e) });
428
+ } finally {
429
+ deleteInFlight = false;
389
430
  }
390
431
  },
391
432
  // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so, and
@@ -397,4 +438,10 @@ const svc = defineService<PostState, {
397
438
  },
398
439
  });
399
440
 
441
+ // The same reset on a runtime from before `notHydrated` (an older binary on a new bundle).
442
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
443
+ const booted = svc.getState?.() as unknown as Record<string, unknown> | undefined;
444
+ if (booted && typeof booted === "object") Object.assign(booted, { deletePending: false, noDeleteConfirmation: true });
445
+ }
446
+
400
447
  export { svc };