@wix/himalaya-cli 0.862.0 → 0.863.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +3 -3
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +1 -1
- package/templates/beauty-salon/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/beauty-salon/tier5-src/_shared/home-service.ts +23 -3
- package/templates/beauty-salon-owner/dev/index.ts +1 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/beauty-salon-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/branded-lite/dev/index.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/branded-lite/tier5-src/_shared/home-service.ts +23 -3
- package/templates/community-club/dev/index.ts +1 -1
- package/templates/community-club/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/community-club/tier5-src/_shared/home-service.ts +23 -3
- package/templates/community-club-owner/dev/index.ts +1 -1
- package/templates/community-club-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/community-club-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/events-meetups/dev/index.ts +1 -1
- package/templates/events-meetups/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/events-meetups/tier5-src/_shared/home-service.ts +23 -3
- package/templates/events-meetups-owner/dev/index.ts +1 -1
- package/templates/events-meetups-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/events-meetups-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/gym-fitness/dev/index.ts +1 -1
- package/templates/gym-fitness/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/gym-fitness/tier5-src/_shared/home-service.ts +23 -3
- package/templates/gym-fitness-owner/dev/index.ts +1 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/gym-fitness-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/nonprofit-cause/dev/index.ts +1 -1
- package/templates/nonprofit-cause/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/nonprofit-cause/tier5-src/_shared/home-service.ts +23 -3
- package/templates/nonprofit-cause-owner/dev/index.ts +1 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/nonprofit-cause-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/owner-lite/dev/index.ts +1 -1
- package/templates/owner-lite/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/owner-lite/tier5-src/_shared/home-service.ts +23 -3
- package/templates/podcast-creator/dev/index.ts +1 -1
- package/templates/podcast-creator/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/podcast-creator/tier5-src/_shared/home-service.ts +23 -3
- package/templates/podcast-creator-owner/dev/index.ts +1 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/podcast-creator-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/portfolio-creative/dev/index.ts +1 -1
- package/templates/portfolio-creative/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/portfolio-creative/tier5-src/_shared/home-service.ts +23 -3
- package/templates/portfolio-creative-owner/dev/index.ts +1 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/portfolio-creative-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/restaurant/dev/index.ts +1 -1
- package/templates/restaurant/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/restaurant/tier5-src/_shared/home-service.ts +23 -3
- package/templates/restaurant-owner/dev/index.ts +1 -1
- package/templates/restaurant-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/restaurant-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/retail-store/dev/index.ts +1 -1
- package/templates/retail-store/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/retail-store/tier5-src/_shared/home-service.ts +23 -3
- package/templates/retail-store-owner/dev/index.ts +1 -1
- package/templates/retail-store-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/retail-store-owner/tier5-src/_shared/home-service.ts +23 -3
- package/templates/yoga-studio/dev/index.ts +1 -1
- package/templates/yoga-studio/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/yoga-studio/tier5-src/_shared/home-service.ts +23 -3
- package/templates/yoga-studio-owner/dev/index.ts +1 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/home-modules.ts +27 -1
- package/templates/yoga-studio-owner/tier5-src/_shared/home-service.ts +23 -3
|
@@ -49314,7 +49314,7 @@ const SCREENS = runtimeSetting({
|
|
|
49314
49314
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49315
49315
|
"params": {
|
|
49316
49316
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the programme\",\"ownerHome.briefCta\":\"Show me the run of show\",\"ownerHome.briefSubtitle\":\"What’s doors-open next, how the registration numbers are tracking, and which announcement is still a draft.\",\"ownerHome.briefTitle\":\"Next up on the schedule\",\"ownerHome.errorLabel\":\"Couldn’t load the run of show\",\"ownerHome.headerSubtitle\":\"Organiser · run of show\",\"ownerHome.openManage\":\"Open the programme\",\"ownerHome.statEvents\":\"Scheduled sessions\",\"ownerHome.statPosts\":\"Announcements\",\"ownerHome.statSites\":\"Event series\",\"ownerHome.switchBusiness\":\"Switch event series\"}",
|
|
49317
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49317
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49318
49318
|
"enabledSources": "[\"posts\",\"sites\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49319
49319
|
}
|
|
49320
49320
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -2361,7 +2361,7 @@ const SCREENS = runtimeSetting({
|
|
|
2361
2361
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2362
2362
|
"params": {
|
|
2363
2363
|
"verticals": "[\"store\",\"bookings\",\"plans\",\"events\",\"blog\"]",
|
|
2364
|
-
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\"},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\"},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"trainingFirst.section0\"},{\"id\":\"section_1\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"trainingFirst.section1\"}]",
|
|
2364
|
+
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"This week's schedule\",\"dataSource\":\"member.bookings.schedule\",\"emptyWhen\":[\"hideMemberProgress\"]},{\"id\":\"up_next\",\"label\":\"Up next\",\"dataSource\":\"member.bookings\",\"emptyWhen\":[\"hideNextBooking\",\"hidePersonalError\"]},{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"trainingFirst.section0\",\"emptyWhen\":[\"hideHeroImage\"]},{\"id\":\"section_1\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"trainingFirst.section1\"}]",
|
|
2365
2365
|
"ownerDefaultLayout": "trainingFirst",
|
|
2366
2366
|
"brandAccent": "#C1552F",
|
|
2367
2367
|
"copy": "{\"home.storeFeaturedTitle\":\"Shop the gear\"}"
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -49747,7 +49747,7 @@ const SCREENS = runtimeSetting({
|
|
|
49747
49747
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49748
49748
|
"params": {
|
|
49749
49749
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the gym\",\"ownerHome.briefCta\":\"Show me the floor\",\"ownerHome.briefSubtitle\":\"Which classes are filling, which are empty, what the pro shop owes, and who’s signed up for the next challenge.\",\"ownerHome.briefTitle\":\"The floor today\",\"ownerHome.errorLabel\":\"Couldn’t load the floor\",\"ownerHome.headerSubtitle\":\"Gym owner · the floor today\",\"ownerHome.openManage\":\"Open the gym\",\"ownerHome.ordersDaily\":\"Pro shop sales · daily\",\"ownerHome.statAppointments\":\"Members booked in\",\"ownerHome.statEvents\":\"Challenges ahead\",\"ownerHome.statOrders\":\"Pro shop orders\",\"ownerHome.statPosts\":\"Coach notes\",\"ownerHome.statProducts\":\"Pro shop items\",\"ownerHome.statRevenue\":\"Revenue\",\"ownerHome.statSites\":\"Gyms\",\"ownerHome.switchBusiness\":\"Switch gym\"}",
|
|
49750
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49750
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49751
49751
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"bookings\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49752
49752
|
}
|
|
49753
49753
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -2033,7 +2033,7 @@ const SCREENS = runtimeSetting({
|
|
|
2033
2033
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2034
2034
|
"params": {
|
|
2035
2035
|
"verticals": "[\"plans\",\"feed\",\"events\",\"blog\",\"groups\"]",
|
|
2036
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"communityPulse.section0\"},{\"id\":\"section_1\",\"label\":\"From the field\",\"dataSource\":\"communityPulse.section1\"},{\"id\":\"section_2\",\"label\":\"Chapters & teams\",\"dataSource\":\"communityPulse.section2\"},{\"id\":\"section_3\",\"label\":\"Latest from the community\",\"dataSource\":\"communityPulse.section3\"}]",
|
|
2036
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Discover 1\",\"dataSource\":\"communityPulse.section0\",\"emptyWhen\":[\"noMemberAvatars\"]},{\"id\":\"section_1\",\"label\":\"From the field\",\"dataSource\":\"communityPulse.section1\",\"emptyWhen\":[\"noAnnouncement\",\"noUpcomingEvents\"]},{\"id\":\"section_2\",\"label\":\"Chapters & teams\",\"dataSource\":\"communityPulse.section2\",\"emptyWhen\":[\"noGroupCards\"]},{\"id\":\"section_3\",\"label\":\"Latest from the community\",\"dataSource\":\"communityPulse.section3\"}]",
|
|
2037
2037
|
"ownerDefaultLayout": "communityPulse",
|
|
2038
2038
|
"brandAccent": "#0F6B5C",
|
|
2039
2039
|
"copy": "{\"home.activityTitle\":\"Latest from the community\",\"home.announcementCaption\":\"From the field\",\"home.eventsTitle\":\"Volunteer opportunities\",\"home.groupsSubtitle\":\"Find your cause and pitch in.\",\"home.groupsTitle\":\"Chapters & teams\",\"home.joinConversationCta\":\"Share your story\",\"home.membersCaption\":\"Our volunteers\",\"home.onboardingBody\":\"Find a way to give, sign up to volunteer, and follow the mission's impact — all in one place.\"}"
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -49385,7 +49385,7 @@ const SCREENS = runtimeSetting({
|
|
|
49385
49385
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49386
49386
|
"params": {
|
|
49387
49387
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the organisation\",\"ownerHome.briefCta\":\"Show me the gaps\",\"ownerHome.briefSubtitle\":\"Which shifts are still unfilled, how the next fundraiser is tracking, and which update from the field hasn’t gone out.\",\"ownerHome.briefTitle\":\"Where help is needed\",\"ownerHome.errorLabel\":\"Couldn’t load the coordinator view\",\"ownerHome.headerSubtitle\":\"Programme coordinator · this week\",\"ownerHome.openManage\":\"Open programmes\",\"ownerHome.statAppointments\":\"Volunteer shifts filled\",\"ownerHome.statEvents\":\"Drives & fundraisers\",\"ownerHome.statPosts\":\"Field updates\",\"ownerHome.statSites\":\"Organisations\",\"ownerHome.switchBusiness\":\"Switch organisation\"}",
|
|
49388
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49388
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49389
49389
|
"enabledSources": "[\"posts\",\"sites\",\"bookings\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49390
49390
|
}
|
|
49391
49391
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -49746,7 +49746,7 @@ const SCREENS = runtimeSetting({
|
|
|
49746
49746
|
"onAppear": {
|
|
49747
49747
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49748
49748
|
"params": {
|
|
49749
|
-
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\"},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\"},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\"},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49749
|
+
"homeModules": "[{\"id\":\"attention\",\"label\":\"Needs attention\",\"dataSource\":\"owner-today\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"schedule\",\"label\":\"Up next\",\"dataSource\":\"owner-schedule\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"shortcuts\",\"label\":\"Quick actions\",\"dataSource\":\"owner-actions\"},{\"id\":\"metrics\",\"label\":\"Business overview\",\"dataSource\":\"dashboard\",\"emptyWhen\":[\"hideContent\"]},{\"id\":\"brief\",\"label\":\"Daily brief\",\"dataSource\":\"brief\"}]",
|
|
49750
49750
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"bookings\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49751
49751
|
}
|
|
49752
49752
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
/** Bounded OTA home composition. Module ids, eligibility and defaults are
|
|
2
2
|
* supplied by the signed descriptor; personal state cannot introduce content. */
|
|
3
|
-
|
|
3
|
+
/** `emptyWhen` — state keys that ALL have to be truthy for the module to have nothing
|
|
4
|
+
* to paint. Derived from the descriptor's own `hiddenPath` gates (see
|
|
5
|
+
* `emptyWhenOf` in home-modules-screen.ts), never hand-maintained.
|
|
6
|
+
*
|
|
7
|
+
* It exists because "visible" had two independent meanings that never met: the user's
|
|
8
|
+
* preference (this list) and the content's own `hiddenWhen` gates. A module the user
|
|
9
|
+
* keeps but whose content self-hides still occupied a row, and the list's spacing is
|
|
10
|
+
* applied per ROW — so a signed-out home left four empty rows and a ~95px hole above
|
|
11
|
+
* the first real section. The same arithmetic runs in SwiftUI/Compose VStack spacing,
|
|
12
|
+
* which is why this is filtered in the worker rather than patched in one renderer. */
|
|
13
|
+
export interface HomeModule { id: string; label: string; dataSource: string; emptyWhen?: string[]; }
|
|
4
14
|
export interface HomePreferences { order: string[]; hidden: string[]; pinned: string[]; }
|
|
15
|
+
/** Mirror of the renderers' hidden-gate truthiness (`hiddenBool` on web,
|
|
16
|
+
* `ReactiveHiddenGate.boolOf` on native): bool → itself; number ≠ 0; "true"/"1"
|
|
17
|
+
* case-insensitively. Anything else — including undefined — is NOT hidden, so an
|
|
18
|
+
* unknown key can only ever keep a module on screen. */
|
|
19
|
+
export function hiddenFlag(v: unknown): boolean {
|
|
20
|
+
if (typeof v === 'boolean') return v;
|
|
21
|
+
if (typeof v === 'number') return v !== 0;
|
|
22
|
+
if (typeof v === 'string') { const l = v.trim().toLowerCase(); return l === 'true' || l === '1'; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** True when every one of the module's gates is currently hidden, i.e. it would render
|
|
26
|
+
* an empty row. A module with no declared gates is never empty. */
|
|
27
|
+
export function moduleIsEmpty(module: HomeModule, state: Record<string, unknown>): boolean {
|
|
28
|
+
const gates = module.emptyWhen;
|
|
29
|
+
return Array.isArray(gates) && gates.length > 0 && gates.every(key => hiddenFlag(state[key]));
|
|
30
|
+
}
|
|
5
31
|
export function normalizeHome(modules: HomeModule[], value?: Partial<HomePreferences>): HomePreferences {
|
|
6
32
|
const ids = modules.map(module => module.id);
|
|
7
33
|
const valid = (items: unknown): string[] => Array.isArray(items) ? [...new Set(items.filter((id): id is string => typeof id === 'string' && ids.includes(id)))] : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
-
import { normalizeHome, visibleHome, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
2
|
+
import { normalizeHome, visibleHome, moduleIsEmpty, type HomeModule, type HomePreferences } from './home-modules.js';
|
|
3
3
|
import { copyParam, stashCopy } from './himi-copy.js';
|
|
4
4
|
import { localPreferences } from './local-preferences.js';
|
|
5
5
|
type State = Record<string, unknown>;
|
|
@@ -18,8 +18,18 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
18
18
|
let prefs: HomePreferences = normalizeHome([]);
|
|
19
19
|
let presented = false;
|
|
20
20
|
const derive = (s: State): State => {
|
|
21
|
+
// Two filters, and they answer different questions. `visibleHome` is what the USER
|
|
22
|
+
// chose to keep; `moduleIsEmpty` is whether the module has anything to paint right
|
|
23
|
+
// now. A row that passes the first and fails the second still costs the list its
|
|
24
|
+
// inter-row spacing on every renderer, which is the ~95px hole a signed-out home
|
|
25
|
+
// used to show. `slots` keeps the unfiltered order so a module that fills up later
|
|
26
|
+
// returns to the same place rather than jumping to the end.
|
|
21
27
|
const order = visibleHome(prefs);
|
|
22
|
-
|
|
28
|
+
const painted = order.filter(id => {
|
|
29
|
+
const module = modules.find(m => m.id === id);
|
|
30
|
+
return !module || !moduleIsEmpty(module, s);
|
|
31
|
+
});
|
|
32
|
+
return { ...s, homeModules: { presented, visible: painted.map(id => ({ id })),
|
|
23
33
|
persistenceLabel: persisted.available ? copyParam('homeModules.onDevice', 'Saved on this device') : copyParam('homeModules.sessionOnly', 'Choices last for this session'),
|
|
24
34
|
slots: Object.fromEntries(modules.map((_, i) => [`slot${i}`, order[i] ?? 'empty'])),
|
|
25
35
|
choices: prefs.order.map(id => ({ ...modules.find(module => module.id === id),
|
|
@@ -35,7 +45,17 @@ export function decorateHome(def: { state: State; actions?: Record<string, Actio
|
|
|
35
45
|
try {
|
|
36
46
|
const parsed: unknown = JSON.parse(arg.homeModules);
|
|
37
47
|
if (Array.isArray(parsed)) {
|
|
38
|
-
modules = parsed
|
|
48
|
+
modules = parsed
|
|
49
|
+
.filter((m): m is HomeModule => typeof m?.id === 'string' && /^[a-z][a-z0-9_-]{0,40}$/.test(m.id) && typeof m.label === 'string')
|
|
50
|
+
.slice(0, 5)
|
|
51
|
+
// Same shape rule as the ids above: a gate is a plain state key, never a
|
|
52
|
+
// path. Anything else is dropped, which can only leave a module ON screen.
|
|
53
|
+
.map(module => {
|
|
54
|
+
const gates = Array.isArray(module.emptyWhen)
|
|
55
|
+
? module.emptyWhen.filter((k): k is string => typeof k === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,60}$/.test(k))
|
|
56
|
+
: [];
|
|
57
|
+
return gates.length ? { ...module, emptyWhen: gates } : { ...module, emptyWhen: undefined };
|
|
58
|
+
});
|
|
39
59
|
prefs = normalizeHome(modules, receivedRegistry ? prefs : restored);
|
|
40
60
|
receivedRegistry = true;
|
|
41
61
|
}
|
|
@@ -1985,7 +1985,7 @@ const SCREENS = runtimeSetting({
|
|
|
1985
1985
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
1986
1986
|
"params": {
|
|
1987
1987
|
"verticals": "[\"plans\",\"feed\",\"events\",\"blog\",\"podcasts\"]",
|
|
1988
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Latest episode\",\"dataSource\":\"episodeFirst.section0\"},{\"id\":\"section_1\",\"label\":\"All episodes\",\"dataSource\":\"episodeFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Discover 3\",\"dataSource\":\"episodeFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Latest activity\",\"dataSource\":\"episodeFirst.section3\"}]",
|
|
1988
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Latest episode\",\"dataSource\":\"episodeFirst.section0\",\"emptyWhen\":[\"noLatestPost\"]},{\"id\":\"section_1\",\"label\":\"All episodes\",\"dataSource\":\"episodeFirst.section1\",\"emptyWhen\":[\"noPostCards\"]},{\"id\":\"section_2\",\"label\":\"Discover 3\",\"dataSource\":\"episodeFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Latest activity\",\"dataSource\":\"episodeFirst.section3\"}]",
|
|
1989
1989
|
"ownerDefaultLayout": "episodeFirst",
|
|
1990
1990
|
"brandAccent": "#6E4BFF",
|
|
1991
1991
|
"copy": "{\"home.allEpisodesCta\":\"Browse all episodes\",\"home.episodesTitle\":\"All episodes\",\"home.latestEpisodeCaption\":\"Latest episode\",\"home.listenCta\":\"Listen now\",\"home.listenersCaption\":\"Our listeners\",\"home.notesCta\":\"Read the show notes\",\"home.onboardingBody\":\"Catch the latest episode, read the show notes, meet other listeners and support the show — all in one place.\",\"home.supportSubtitle\":\"Join a membership and keep new episodes coming.\",\"home.supportTitle\":\"Support the show\"}"
|