@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
|
@@ -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
|
}
|
|
@@ -49676,7 +49676,7 @@ const SCREENS = runtimeSetting({
|
|
|
49676
49676
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49677
49677
|
"params": {
|
|
49678
49678
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the show\",\"ownerHome.briefCta\":\"Show me the desk\",\"ownerHome.briefSubtitle\":\"Which episode is still a draft, what merch is moving, and how the next live taping is filling up.\",\"ownerHome.briefTitle\":\"The show desk\",\"ownerHome.errorLabel\":\"Couldn’t load the show desk\",\"ownerHome.headerSubtitle\":\"Creator · the show desk\",\"ownerHome.openManage\":\"Open the show\",\"ownerHome.ordersDaily\":\"Merch sales · daily\",\"ownerHome.statEvents\":\"Live tapings ahead\",\"ownerHome.statOrders\":\"Merch orders\",\"ownerHome.statPosts\":\"Episodes\",\"ownerHome.statProducts\":\"Merch designs\",\"ownerHome.statRevenue\":\"Merch revenue\",\"ownerHome.statSites\":\"Shows\",\"ownerHome.switchBusiness\":\"Switch show\"}",
|
|
49679
|
-
"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\"}]",
|
|
49679
|
+
"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\"}]",
|
|
49680
49680
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"events\",\"members\",\"reviews\",\"reservations\"]"
|
|
49681
49681
|
}
|
|
49682
49682
|
}
|
|
@@ -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
|
}
|
|
@@ -1852,7 +1852,7 @@ const SCREENS = runtimeSetting({
|
|
|
1852
1852
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
1853
1853
|
"params": {
|
|
1854
1854
|
"verticals": "[\"store\",\"portfolio\",\"events\",\"blog\"]",
|
|
1855
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Selected work\",\"dataSource\":\"showcaseFirst.section0\"},{\"id\":\"section_1\",\"label\":\"Discover 2\",\"dataSource\":\"showcaseFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Prints & editions\",\"dataSource\":\"showcaseFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Exhibitions & fairs\",\"dataSource\":\"showcaseFirst.section3\"}]",
|
|
1855
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Selected work\",\"dataSource\":\"showcaseFirst.section0\",\"emptyWhen\":[\"noPostCards\"]},{\"id\":\"section_1\",\"label\":\"Discover 2\",\"dataSource\":\"showcaseFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Prints & editions\",\"dataSource\":\"showcaseFirst.section2\",\"emptyWhen\":[\"noFeaturedProducts\"]},{\"id\":\"section_3\",\"label\":\"Exhibitions & fairs\",\"dataSource\":\"showcaseFirst.section3\"}]",
|
|
1856
1856
|
"ownerDefaultLayout": "showcaseFirst",
|
|
1857
1857
|
"brandAccent": "#B0511F",
|
|
1858
1858
|
"copy": "{\"home.aboutTitle\":\"About the studio\",\"home.contactCta\":\"Get in touch\",\"home.exhibitionsTitle\":\"Exhibitions & fairs\",\"home.onboardingBody\":\"Browse the latest work, pick up a print, and catch the next exhibition — all in one place.\",\"home.printsTitle\":\"Prints & editions\",\"home.viewWorkCta\":\"View all work\",\"home.workSubtitle\":\"The latest pieces, straight from the studio.\",\"home.workTitle\":\"Selected work\"}"
|
|
@@ -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
|
}
|
|
@@ -49676,7 +49676,7 @@ const SCREENS = runtimeSetting({
|
|
|
49676
49676
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49677
49677
|
"params": {
|
|
49678
49678
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the practice\",\"ownerHome.briefCta\":\"Show me the studio\",\"ownerHome.briefSubtitle\":\"Which shoot is next, which print orders are waiting on the framer, and what’s still sitting unpublished in the journal.\",\"ownerHome.briefTitle\":\"In the studio today\",\"ownerHome.errorLabel\":\"Couldn’t load the studio\",\"ownerHome.headerSubtitle\":\"Studio · commissions and prints\",\"ownerHome.openManage\":\"Open the practice\",\"ownerHome.ordersDaily\":\"Print sales · daily\",\"ownerHome.statAppointments\":\"Sessions booked\",\"ownerHome.statOrders\":\"Print orders\",\"ownerHome.statPosts\":\"Journal entries\",\"ownerHome.statProducts\":\"Prints for sale\",\"ownerHome.statRevenue\":\"Earned\",\"ownerHome.statSites\":\"Studios\",\"ownerHome.switchBusiness\":\"Switch studio\"}",
|
|
49679
|
-
"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\"}]",
|
|
49679
|
+
"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\"}]",
|
|
49680
49680
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"bookings\",\"members\",\"reviews\",\"reservations\"]"
|
|
49681
49681
|
}
|
|
49682
49682
|
}
|
|
@@ -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
|
}
|
|
@@ -1903,7 +1903,7 @@ const SCREENS = runtimeSetting({
|
|
|
1903
1903
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
1904
1904
|
"params": {
|
|
1905
1905
|
"verticals": "[\"restaurants\",\"events\",\"blog\"]",
|
|
1906
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"menuFirst.section0\"},{\"id\":\"section_1\",\"label\":\"Discover 2\",\"dataSource\":\"menuFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Popular dishes\",\"dataSource\":\"menuFirst.section2\"},{\"id\":\"section_3\",\"label\":\"Find us\",\"dataSource\":\"menuFirst.section3\"}]",
|
|
1906
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"__HIMI_APP_NAME__\",\"dataSource\":\"menuFirst.section0\",\"emptyWhen\":[\"hasDishHeroImage\"]},{\"id\":\"section_1\",\"label\":\"Discover 2\",\"dataSource\":\"menuFirst.section1\",\"emptyWhen\":[\"noMenuCategories\"]},{\"id\":\"section_2\",\"label\":\"Popular dishes\",\"dataSource\":\"menuFirst.section2\",\"emptyWhen\":[\"noPopularDishes\"]},{\"id\":\"section_3\",\"label\":\"Find us\",\"dataSource\":\"menuFirst.section3\"}]",
|
|
1907
1907
|
"ownerDefaultLayout": "menuFirst",
|
|
1908
1908
|
"brandAccent": "#B8544A",
|
|
1909
1909
|
"copy": "{\"home.eventsTitle\":\"Wine nights & events\",\"home.findUsTitle\":\"Find us\",\"home.hoursTitle\":\"Opening hours\",\"home.onboardingBody\":\"Browse the menu, reserve a table, and keep up with wine nights and kitchen news — all in one place.\",\"home.popularSubtitle\":\"The plates everyone keeps coming back for.\",\"home.popularTitle\":\"Popular dishes\",\"home.reserveCta\":\"Reserve a table\"}"
|
|
@@ -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 venue\",\"ownerHome.briefCta\":\"Show me service\",\"ownerHome.briefSubtitle\":\"Covers booked, online orders still open, what’s 86’d on the menu, and who’s coming to the next wine night.\",\"ownerHome.briefTitle\":\"Tonight’s service\",\"ownerHome.errorLabel\":\"Couldn’t load tonight’s service\",\"ownerHome.headerSubtitle\":\"Owner · tonight’s service\",\"ownerHome.openManage\":\"Open front of house\",\"ownerHome.ordersDaily\":\"Online orders · daily\",\"ownerHome.statAppointments\":\"Covers booked\",\"ownerHome.statEvents\":\"Wine nights ahead\",\"ownerHome.statOrders\":\"Online orders\",\"ownerHome.statPosts\":\"Kitchen notes\",\"ownerHome.statProducts\":\"Menu items\",\"ownerHome.statRevenue\":\"Takings\",\"ownerHome.statSites\":\"Venues\",\"ownerHome.switchBusiness\":\"Switch venue\"}",
|
|
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
|
}
|
|
@@ -1390,7 +1390,7 @@ const SCREENS = runtimeSetting({
|
|
|
1390
1390
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
1391
1391
|
"params": {
|
|
1392
1392
|
"verticals": "[\"store\",\"blog\"]",
|
|
1393
|
-
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\"},{\"id\":\"section_0\",\"label\":\"Shop the collection\",\"dataSource\":\"storeFirst.section0\"},{\"id\":\"section_1\",\"label\":\"Explore\",\"dataSource\":\"storeFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Shop\",\"dataSource\":\"storeFirst.section2\"}]",
|
|
1393
|
+
"homeModules": "[{\"id\":\"saved\",\"label\":\"Saved on this device\",\"dataSource\":\"device.saved_items\",\"emptyWhen\":[\"noSavedContent\"]},{\"id\":\"section_0\",\"label\":\"Shop the collection\",\"dataSource\":\"storeFirst.section0\"},{\"id\":\"section_1\",\"label\":\"Explore\",\"dataSource\":\"storeFirst.section1\"},{\"id\":\"section_2\",\"label\":\"Shop\",\"dataSource\":\"storeFirst.section2\"}]",
|
|
1394
1394
|
"ownerDefaultLayout": "storeFirst",
|
|
1395
1395
|
"brandAccent": "#B4552D"
|
|
1396
1396
|
}
|