@growth-operations/ui-shared 1.22.0 → 1.24.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/package.json +1 -1
- package/src/home/AppHome.jsx +1 -1
- package/src/home/FeatureUsage.jsx +53 -16
- package/src/index.js +6 -1
- package/src/lib/featureUsage.js +47 -0
package/package.json
CHANGED
package/src/home/AppHome.jsx
CHANGED
|
@@ -159,7 +159,7 @@ export function AppHome({ state, onNavigate, appName, onRetry, helpfulLinks }) {
|
|
|
159
159
|
on the non-billing setup steps (NOT the paid-gated plan step) so a
|
|
160
160
|
fresh install sees setup first but free/trial installs aren't
|
|
161
161
|
locked out. */}
|
|
162
|
-
{setupStepsComplete && <FeatureUsage state={state} />}
|
|
162
|
+
{setupStepsComplete && <FeatureUsage state={state} onNavigate={onNavigate} />}
|
|
163
163
|
|
|
164
164
|
<FeatureNudges nudges={state.nudges} onNavigate={onNavigate} />
|
|
165
165
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Flex, Tile, Text, Link } from "@hubspot/ui-extensions";
|
|
3
|
-
import {
|
|
2
|
+
import { Flex, Tile, Text, Link, Button } from "@hubspot/ui-extensions";
|
|
3
|
+
import {
|
|
4
|
+
featureUsageOf,
|
|
5
|
+
pickDailyRecommendations,
|
|
6
|
+
recommendationsRotated,
|
|
7
|
+
} from "../lib/featureUsage";
|
|
4
8
|
|
|
5
9
|
// The Home "Most used features" / "Recommended to explore" sections, fed by
|
|
6
10
|
// the additive state.feature_usage block (per-app feature registry + credit
|
|
@@ -42,35 +46,68 @@ function MostUsedTile({ mostUsed }) {
|
|
|
42
46
|
);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
function RecommendedTile({ notInUse }) {
|
|
49
|
+
function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
|
|
46
50
|
if (notInUse.length === 0) return null;
|
|
51
|
+
// Long lists (ATS: 13 features) render as a rotating daily slice instead of
|
|
52
|
+
// a wall — the whole list still arrives in the payload; the window is a
|
|
53
|
+
// pure function of the UTC date (see ../lib/featureUsage).
|
|
54
|
+
const picks = pickDailyRecommendations(
|
|
55
|
+
notInUse,
|
|
56
|
+
new Date().toISOString().slice(0, 10)
|
|
57
|
+
);
|
|
47
58
|
return (
|
|
48
59
|
<Tile>
|
|
49
60
|
<Flex direction="column" gap="small">
|
|
50
61
|
<Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
{picks.map((f) => {
|
|
63
|
+
// Setup CTAs point at admin-only surfaces (the layout editor above
|
|
64
|
+
// all) — show them to super admins only; everyone else keeps the
|
|
65
|
+
// docs link (a non-admin clicking into a HubSpot permission wall is
|
|
66
|
+
// worse than no link).
|
|
67
|
+
const showCta = isSuperAdmin && f.ctaLabel && (f.ctaUrl || f.ctaRoute);
|
|
68
|
+
return (
|
|
69
|
+
<Flex key={f.key} direction="column" gap="extra-small">
|
|
70
|
+
<Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
|
|
71
|
+
{f.rationale ? <Text>{f.rationale}</Text> : null}
|
|
72
|
+
{showCta && f.ctaUrl ? (
|
|
73
|
+
<Link href={{ url: f.ctaUrl, external: true }}>
|
|
74
|
+
{f.ctaLabel} →
|
|
75
|
+
</Link>
|
|
76
|
+
) : null}
|
|
77
|
+
{showCta && !f.ctaUrl && f.ctaRoute && onNavigate ? (
|
|
78
|
+
<Button variant="secondary" onClick={() => onNavigate(f.ctaRoute)}>
|
|
79
|
+
{f.ctaLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
) : null}
|
|
82
|
+
{f.docsUrl ? (
|
|
83
|
+
<Link href={{ url: f.docsUrl, external: true }}>
|
|
84
|
+
Learn more →
|
|
85
|
+
</Link>
|
|
86
|
+
) : null}
|
|
87
|
+
</Flex>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
{recommendationsRotated(notInUse) ? (
|
|
91
|
+
<Text variant="microcopy">
|
|
92
|
+
New picks daily — {notInUse.length} features to explore in all.
|
|
93
|
+
</Text>
|
|
94
|
+
) : null}
|
|
62
95
|
</Flex>
|
|
63
96
|
</Tile>
|
|
64
97
|
);
|
|
65
98
|
}
|
|
66
99
|
|
|
67
|
-
export function FeatureUsage({ state }) {
|
|
100
|
+
export function FeatureUsage({ state, onNavigate }) {
|
|
68
101
|
const view = featureUsageOf(state);
|
|
69
102
|
if (!view) return null;
|
|
70
103
|
return (
|
|
71
104
|
<>
|
|
72
105
|
<MostUsedTile mostUsed={view.mostUsed} />
|
|
73
|
-
<RecommendedTile
|
|
106
|
+
<RecommendedTile
|
|
107
|
+
notInUse={view.notInUse}
|
|
108
|
+
isSuperAdmin={state?.user?.is_super_admin === true}
|
|
109
|
+
onNavigate={onNavigate}
|
|
110
|
+
/>
|
|
74
111
|
</>
|
|
75
112
|
);
|
|
76
113
|
}
|
package/src/index.js
CHANGED
|
@@ -50,7 +50,12 @@ export {
|
|
|
50
50
|
SUPPORT_FOOTER,
|
|
51
51
|
} from "./lib/format";
|
|
52
52
|
export { useStrictModeEffect } from "./lib/useStrictModeEffect";
|
|
53
|
-
export {
|
|
53
|
+
export {
|
|
54
|
+
resolveFeatureUsage,
|
|
55
|
+
featureUsageOf,
|
|
56
|
+
pickDailyRecommendations,
|
|
57
|
+
recommendationsRotated,
|
|
58
|
+
} from "./lib/featureUsage";
|
|
54
59
|
export {
|
|
55
60
|
hasTopUpBank,
|
|
56
61
|
creditsBlockOf,
|
package/src/lib/featureUsage.js
CHANGED
|
@@ -39,6 +39,12 @@ export function resolveFeatureUsage(featureUsage) {
|
|
|
39
39
|
name: f.name,
|
|
40
40
|
docsUrl: typeof f.docs_url === "string" && f.docs_url ? f.docs_url : null,
|
|
41
41
|
rationale: typeof f.rationale === "string" ? f.rationale : "",
|
|
42
|
+
// Optional SETUP action (the same cta_* vocabulary as nudges): the "go
|
|
43
|
+
// do the thing" deep link for an unused feature — layout editor, store
|
|
44
|
+
// listing, or in-app route. Absent on backends that predate it.
|
|
45
|
+
ctaLabel: typeof f.cta_label === "string" && f.cta_label ? f.cta_label : null,
|
|
46
|
+
ctaUrl: typeof f.cta_url === "string" && f.cta_url ? f.cta_url : null,
|
|
47
|
+
ctaRoute: typeof f.cta_route === "string" && f.cta_route ? f.cta_route : null,
|
|
42
48
|
}));
|
|
43
49
|
|
|
44
50
|
if (mostUsed.length === 0 && notInUse.length === 0) return null;
|
|
@@ -52,3 +58,44 @@ export function featureUsageOf(state) {
|
|
|
52
58
|
if (!state || typeof state !== "object") return null;
|
|
53
59
|
return resolveFeatureUsage(state.state?.feature_usage ?? state.feature_usage);
|
|
54
60
|
}
|
|
61
|
+
|
|
62
|
+
// --- Recommendation rotation ------------------------------------------------
|
|
63
|
+
|
|
64
|
+
// A long not_in_use list is a firehose: a fresh install of a big app (ATS
|
|
65
|
+
// has 13 registered features) gets the whole registry dumped on Home exactly
|
|
66
|
+
// when focus matters most, and a wall of 13 "Read the guide" links teaches
|
|
67
|
+
// nothing. So short lists (<= RECOMMENDATION_WHOLE_LIST_MAX) render whole;
|
|
68
|
+
// longer lists show a deterministic daily slice that rotates day over day.
|
|
69
|
+
// The seed is the UTC date alone — same picks all day, no per-portal state,
|
|
70
|
+
// no backend involvement (the full list still arrives in the payload).
|
|
71
|
+
export const RECOMMENDATION_PICK_COUNT = 3;
|
|
72
|
+
export const RECOMMENDATION_WHOLE_LIST_MAX = 4;
|
|
73
|
+
|
|
74
|
+
// djb2 over the date string — tiny, stable across browsers, good enough for
|
|
75
|
+
// a day offset (not a security or money decision).
|
|
76
|
+
export function rotationDayHash(dateISO) {
|
|
77
|
+
let h = 5381;
|
|
78
|
+
for (let i = 0; i < dateISO.length; i++) {
|
|
79
|
+
h = ((h << 5) + h + dateISO.charCodeAt(i)) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
return h;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The slice to render for `todayISO` ("YYYY-MM-DD", UTC). Order within the
|
|
85
|
+
// day stays registry order; the window wraps around the end of the list.
|
|
86
|
+
// Pure + date-injected so tests don't depend on the wall clock.
|
|
87
|
+
export function pickDailyRecommendations(notInUse, todayISO) {
|
|
88
|
+
if (!Array.isArray(notInUse)) return [];
|
|
89
|
+
if (notInUse.length <= RECOMMENDATION_WHOLE_LIST_MAX) return notInUse;
|
|
90
|
+
const start = rotationDayHash(todayISO) % notInUse.length;
|
|
91
|
+
return Array.from(
|
|
92
|
+
{ length: RECOMMENDATION_PICK_COUNT },
|
|
93
|
+
(_, i) => notInUse[(start + i) % notInUse.length]
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// True when rotation truncated the list — the tile shows a quiet "new picks
|
|
98
|
+
// daily" hint only then, so small registries read exactly as before.
|
|
99
|
+
export function recommendationsRotated(notInUse) {
|
|
100
|
+
return Array.isArray(notInUse) && notInUse.length > RECOMMENDATION_WHOLE_LIST_MAX;
|
|
101
|
+
}
|