@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
|
}
|
|
@@ -49593,7 +49593,7 @@ const SCREENS = runtimeSetting({
|
|
|
49593
49593
|
"actionId": "tier5.himiOwnerLiteHome.load",
|
|
49594
49594
|
"params": {
|
|
49595
49595
|
"copy": "{\"ownerHome.acrossBusiness\":\"Across the shop\",\"ownerHome.briefCta\":\"Show me what’s waiting\",\"ownerHome.briefSubtitle\":\"Orders waiting to be packed, payments that never landed, and the lines that have run out.\",\"ownerHome.briefTitle\":\"Back office\",\"ownerHome.errorLabel\":\"Couldn’t load the back office\",\"ownerHome.headerSubtitle\":\"Shop owner · back office\",\"ownerHome.openManage\":\"Open the shop\",\"ownerHome.ordersDaily\":\"Orders · daily\",\"ownerHome.statOrders\":\"Orders\",\"ownerHome.statPosts\":\"Shop journal\",\"ownerHome.statProducts\":\"Catalog lines\",\"ownerHome.statRevenue\":\"Revenue\",\"ownerHome.statSites\":\"Shops\",\"ownerHome.switchBusiness\":\"Switch shop\"}",
|
|
49596
|
-
"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\"}]",
|
|
49596
|
+
"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\"}]",
|
|
49597
49597
|
"enabledSources": "[\"products\",\"posts\",\"sites\",\"orders\",\"members\",\"reviews\",\"reservations\"]"
|
|
49598
49598
|
}
|
|
49599
49599
|
}
|
|
@@ -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
|
}
|
|
@@ -2287,7 +2287,7 @@ const SCREENS = runtimeSetting({
|
|
|
2287
2287
|
"actionId": "tier5.himiBrandedHome.refresh",
|
|
2288
2288
|
"params": {
|
|
2289
2289
|
"verticals": "[\"bookings\",\"programs\",\"plans\",\"events\",\"blog\"]",
|
|
2290
|
-
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"Today'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\":\"Today's schedule\",\"dataSource\":\"calmStudio.section0\"},{\"id\":\"section_1\",\"label\":\"Your practice\",\"dataSource\":\"calmStudio.section1\"}]",
|
|
2290
|
+
"homeModules": "[{\"id\":\"member_progress\",\"label\":\"Today'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\":\"Today's schedule\",\"dataSource\":\"calmStudio.section0\",\"emptyWhen\":[\"noTodaysClasses\"]},{\"id\":\"section_1\",\"label\":\"Your practice\",\"dataSource\":\"calmStudio.section1\"}]",
|
|
2291
2291
|
"ownerDefaultLayout": "calmStudio",
|
|
2292
2292
|
"brandAccent": "#5F7360",
|
|
2293
2293
|
"copy": "{\"home.bookClassCta\":\"Reserve your spot\",\"home.onboardingBody\":\"Reserve classes, follow programs, and join retreats — all from one calm place.\",\"home.practiceTitle\":\"Your practice\",\"home.retreatsTitle\":\"Retreats & workshops\",\"home.scheduleTitle\":\"Today's schedule\",\"home.teachersCaption\":\"Your teachers\"}"
|
|
@@ -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 studio\",\"ownerHome.briefCta\":\"Show me today\",\"ownerHome.briefSubtitle\":\"Which classes have space on the mat, who is booked in, and whether the next retreat still needs filling.\",\"ownerHome.briefTitle\":\"Today’s schedule\",\"ownerHome.errorLabel\":\"Couldn’t load today’s schedule\",\"ownerHome.headerSubtitle\":\"Studio owner · today’s schedule\",\"ownerHome.openManage\":\"Open the studio\",\"ownerHome.statAppointments\":\"Students booked in\",\"ownerHome.statEvents\":\"Workshops & retreats\",\"ownerHome.statPosts\":\"Studio journal\",\"ownerHome.statSites\":\"Studios\",\"ownerHome.switchBusiness\":\"Switch studio\"}",
|
|
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
|
}
|