feat/discovery
needs attentionviewing older commita48e3ff · incrementalPR #305reviewed 2026-07-14 18:22 UTC5H · 8M · 6L · 5I- Purpose
- Feature Discovery v1 — ambient spotlights, a persistent Descubre hub, and a first-run linear tour, all flag-gated (feature-discovery-spotlights, OFF by default)
- Goal
- Contextual feature discovery for existing users: guide them to CFE setup → add RPU → monitoring → analytics in a low-friction, interruptible flow
- Sub-goals
- SG-1: Ambient spotlights — one contextual coachmark per rolling day, paced, retire-on-dismiss
- SG-2: Descubre hub — persistent header badge + checklist, re-launchable tutorials per item
- SG-3: First-run linear product tour (GuidedTour) — 5 fixed steps, scrim + spotlight cutout
- SG-4: RPU sub-tour (RpuSubTour) — interactive walkthrough of the Nuevo RPU drawer, triggered by ?subtour=rpu
- SG-5: Tour hand-off continuity — CFE step and RPU step hand off to dedicated flows and offer to resume the tour on completion
- SG-6: Descubre item re-launch — completed checklist items click through to re-watch their tutorial
- What
- This incremental window (5 commits) builds on the base discovery feature by closing two UX dead-ends that previous review flagged: (1) the RPU sub-tour is now interactive (scrim allows drawer interaction), and the 'Seguir el recorrido?' toast correctly resumes the main tour after sub-tour completion; (2) the CFE credentials step now offers a resume toast after saving, giving parity with the RPU path. Completed Descubre items are now clickable and re-launch their tutorial. The TourOverlay component was extracted from GuidedTour into a shared component reused by RpuSubTour.
- Why
- Prior reviews found the tour dead-ended after 'Hazlo ahora' actions — users who acted on CFE setup or RPU registration had no way back to the tour. These commits close that gap without interrupting users who don't want to continue.
- Areas
- apps/platform/src/components/discovery+555−203apps/platform/src/app/[locale]/(dashboard)+75−30packages/analytics/src+23−0apps/platform/src/messages+70−2
- Blast
- 13 files changed in this window (+723/−235); 56 files total across the branch (+4414/−61). All changes are flag-gated — no user impact until feature-discovery-spotlights is enabled. The AddContractDrawer change (guidedOpen prop) is additive and non-breaking.
Findings · 23
correctness3
handleUpdate on CFE page ignores TOUR_RETURN_PARAM — dead-end for users with existing credentials
apps/platform/src/app/[locale]/(dashboard)/credentials/cfe/page.tsx:141
The tour hand-off (show 'Seguir el recorrido?' toast after saving) lives only in handleCreate. handleUpdate, which runs when the user already has credentials and updates them, never checks TOUR_RETURN_PARAM. A user arriving at /credentials/cfe?tour_return=1 with existing credentials will get a success toast and be stranded with no path back to the tour. Fix: copy the TOUR_RETURN_PARAM block from handleCreate into handleUpdate.
handleStep skip check uses string id match — fragile on catalog reorder
apps/platform/src/components/discovery/RpuSubTour.tsx:93
RPU_SUBTOUR_STEPS[next]?.id === 'rpu' ties the skip-ahead to a string id. If RPU_SUBTOUR_STEPS is reordered and 'rpu' moves to the last position, setStepIdx(next + 1) would produce an out-of-bounds index (TourOverlay does steps[stepIdx]! which would throw). Prefer: next === RPU_SUBTOUR_STEPS.findIndex(s => s.id === 'rpu') + 1, or a named constant for the step index.
GuidedTour forced open always records source='hub' — misattributes sub-tour resume events
apps/platform/src/components/discovery/GuidedTour.tsx:102
track({ name: 'discovery.tour_started', properties: { source: 'hub' } }) fires for any forced open, including sub-tour resumes and CFE-credential resumes. PostHog will misattribute these as hub relaunches. Fix: pass a source hint via the URL (?tour=1&step=3&src=subtour_resume) and read it in the effect.
security1
revisitRaw in SPOTLIGHT_CTA passes prototype property names
apps/platform/src/components/discovery/FeatureSpotlight.tsx:56
The `in` operator traverses the prototype chain: __proto__, toString, constructor all satisfy the check. Impact is contained (server rejects invalid keys via DiscoveryFeatureKeySchema; no navigation or anchor-click fires; i18n renders a missing-key fallback), but intent should be explicit. Fix: Object.hasOwn(SPOTLIGHT_CTA, revisitRaw).
conventions5
useTranslations(namespace) with runtime string disables next-intl type safety
apps/platform/src/components/discovery/TourOverlay.tsx:61
next-intl's useTranslations() expects a statically-known namespace to validate key paths at compile time. Passing a runtime prop (const t = useTranslations(namespace)) silently disables all compile-time key checking for every t() call in TourOverlay. Every other component in the codebase uses a string literal. Fix: resolve translations in the parent (GuidedTour / RpuSubTour) and pass typed label props (e.g. labels: {skip, prev, next, finish, now?}) down to TourOverlay.
Missing 'now' key in discovery.subtour.rpu namespace — latent runtime crash
apps/platform/src/messages/en.json:3259
TourOverlay calls t('now') when step.hazloAhora is truthy. The discovery.subtour.rpu namespace (in both en.json and es.json) has skip/prev/next/finish but no 'now' key. None of RPU_SUBTOUR_STEPS currently define hazloAhora so the branch is latent — but TourOverlay's JSDoc explicitly advertises the 'now' key is required. Any future subtour step that adds hazloAhora will throw at runtime. Fix: add 'now' to both locales, OR make onHazloAhora optional in TourOverlayProps and only render the button when the prop is provided.
onHazloAhora required in TourOverlayProps but RpuSubTour can only pass a no-op
apps/platform/src/components/discovery/TourOverlay.tsx:27
TourOverlayProps declares onHazloAhora as a required prop. RpuSubTour passes () => {} because none of RPU_SUBTOUR_STEPS define hazloAhora. The button is only rendered when step.hazloAhora is truthy so the no-op is never invoked today — but it silently swallows a navigation if a future subtour step adds hazloAhora. Fix: mark as optional (readonly onHazloAhora?: (step: TourStep) => void) and drop the dead prop from RpuSubTour.
discoverySearchParams alias for useSearchParams is misleading
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx:175
const discoverySearchParams = useSearchParams() implies the hook is discovery-specific, but it returns all URL params. Future editors may add a redundant hook call for other params. Rename to searchParams or reuse an existing hook call.
Magic constants TOUR_RESUME_STEP_AFTER_* lack step-id cross-reference in docs
apps/platform/src/components/discovery/tour-catalog.ts:68
The docstrings say 'recibos' for TOUR_RESUME_STEP_AFTER_RPU and 'rpu' for TOUR_RESUME_STEP_AFTER_CFE but don't name which index they are — a reader must manually count TOUR_STEPS to verify. If findIndex derivation isn't adopted, at minimum add '// index 3 = recibos step' to the constant.
tests3
GuidedTour forced re-open (sub-tour resume) has no regression test
apps/platform/src/components/discovery/GuidedTour.tsx:95
The fix for 'tour resume was swallowed' (commit 6704f655) removed the openedRef guard from the forced-open path. No test pins this invariant: that forced=true re-opens the tour at the specified startStep even when openedRef.current is already true. A future cleanup restoring the guard would silently re-break the sub-tour resume hand-off.
tutorialHref routing for done add_contract items is untested
apps/platform/src/components/discovery/DescubreMenu.tsx:59
tutorialHref has three cases: add_contract → subtour URL (regardless of status), done others → ?revisit=key, available → ?descubre=key. If the add_contract guard is inverted or removed, a done add_contract item silently navigates to ?revisit=add_contract (showing the spotlight coachmark instead of the sub-tour). This pure routing function is trivially unit-testable.
revisit pacing guard (report no-op when revisit=true) has no test
apps/platform/src/components/discovery/FeatureSpotlight.tsx:172
The if (revisit) return guard in report() ensures re-watching a completed feature never writes 'seen' to the pacing engine. No test pins this invariant. A silent removal of the guard would let a revisit spend the budget and permanently retire the spotlight for a user who only re-watched it.
improvement6
step-viewed analytics fires spuriously on tour close (fragile guard)
apps/platform/src/components/discovery/GuidedTour.tsx:121
useEffect([open, stepIdx]) fires when open goes false (tour closes) while stepIdx still holds the last index. An if (!open) return guard prevents the spurious event today, but removing open from the dep array (a natural 'cleanup') re-enables it silently. Restructure so the effect depends only on stepIdx and uses a ref to track tour-open state, making it structurally correct rather than relying on an easy-to-break early return.
DESKTOP_QUERY and gating expression duplicated across 3 discovery components
apps/platform/src/components/discovery/RpuSubTour.tsx:37
const DESKTOP_QUERY = '(min-width: 1024px)' and the useSyncExternalStore matchMedia pattern appear identically in GuidedTour.tsx, RpuSubTour.tsx, and FeatureSpotlight.tsx. The compound gating (flagEnabled || isPreviewDeployment()) && isDesktop && !!orgId is also copy-pasted. A breakpoint or flag change must be applied in all three places. Extract DESKTOP_QUERY and a useDiscoveryEnabled() hook into a shared use-discovery-gate.ts.
TOUR_RESUME_STEP_AFTER_RPU / _CFE are magic numbers with no structural link to TOUR_STEPS
apps/platform/src/components/discovery/tour-catalog.ts:68
TOUR_RESUME_STEP_AFTER_RPU = 3 and TOUR_RESUME_STEP_AFTER_CFE = 2 are bare integer literals. If steps are reordered or inserted, the resume targets silently point to the wrong step — GuidedTour's clamp makes the error silent. Fix: derive them: `export const TOUR_RESUME_STEP_AFTER_RPU = TOUR_STEPS.findIndex(s => s.id === 'recibos')` with a module-load assert !== -1.
handleStep uses DOM presence as a proxy for drawer form state
apps/platform/src/components/discovery/RpuSubTour.tsx:88
handleStep detects an RPU is filled by querying document.querySelector('[data-subtour="sync"]'). A CSS display:none, a conditional render on a different attribute, or a drawer refactor could break this silently — the sub-tour would stop skipping the rpu step when an RPU is already uploaded. The cleaner contract is an onRpuFilled prop/callback from the drawer. At minimum, document the invariant this selector encodes so it's clearly load-bearing.
Tour-resume href built with string interpolation at two call sites
apps/platform/src/app/[locale]/(dashboard)/credentials/cfe/page.tsx:115
The tour-resume URL (`${TOUR_PAGE_PREFIX}?${TOUR_PARAM}=1&${TOUR_STEP_PARAM}=${step}`) is hand-composed in cfe/page.tsx and in RpuSubTour.tsx. Extract a tourResumeHref(step) helper in tour-catalog.ts so TOUR_PAGE_PREFIX or param-name changes propagate atomically.
steps[stepIdx]! non-null assertion should be a defensive guard
apps/platform/src/components/discovery/TourOverlay.tsx:62
If stepIdx ever drifts out of bounds, the assertion throws instead of degrading gracefully. Replace with: const step = steps[stepIdx] ?? steps[0]; if (!step) return null;
info5
startStep Number('0') || 0 is correct — no bug
apps/platform/src/components/discovery/GuidedTour.tsx:41
Number('0') = 0, 0 || 0 = 0. NaN (invalid string) || 0 = 0. The expression correctly handles step=0 and falls back to step 0 on invalid input. No correctness issue.
TOUR_RESUME_STEP_AFTER_CFE=2 and TOUR_RESUME_STEP_AFTER_RPU=3 indices are correct
apps/platform/src/components/discovery/tour-catalog.ts:68
TOUR_STEPS: welcome(0), cfe(1), rpu(2), recibos(3), discover(4). Resuming at rpu(2) after CFE save and recibos(3) after RPU sub-tour are logically correct. CFE page uses the constant (not a hard-coded literal) — the diff description was inaccurate.
tutorialHref omitting resume=1 for done add_contract is intentional
apps/platform/src/components/discovery/DescubreMenu.tsx:59
Re-launching the add_contract sub-tour from a completed checklist item correctly omits &resume=1 — revisiting should not restart the main tour. The resume signal is added only from the tour's own hazloAhora navigate href.
All changed routes are auth-gated — no new unauthenticated surface
apps/platform/src/app/[locale]/(dashboard)/layout.tsx:1
All changed routes are under the (dashboard) route group. Discovery components additionally gate on !!orgId and the PostHog flag or isPreviewDeployment(). No new unauthenticated surface.
Open redirect absent — all router.push() calls use statically-composed relative paths
apps/platform/src/components/discovery/DescubreMenu.tsx:62
pathForPage() returns hardcoded relative paths. next-intl's router is same-origin only. DescubreMenu server response validates item.key and item.page via schema. No open redirect possible.
History · 36 commits
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22current
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08