← all branches

feat/discovery

needs attentionviewing older commit
a48e3ff · incrementalPR #305reviewed 2026-07-14 18:22 UTC5H · 8M · 6L · 5I
The branch
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
The changes (whole branch)
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+555203apps/platform/src/app/[locale]/(dashboard)+7530packages/analytics/src+230apps/platform/src/messages+702
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.
flag-gated: feature-discovery-spotlights (OFF by default) deploy preconditions: PostHog flag creation + segment reclassify script + non-preview verification before flip
ci· no CI checks recorded for this branchcoderabbit· no .coderabbit.yaml found

Findings · 23

correctness3

high

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.

low

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.

low

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

low

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

high

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.

high

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.

medium

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.

low

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.

low

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

high

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.

medium

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.

medium

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

high

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.

medium

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.

medium

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.

medium

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.

medium

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.

low

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

info

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.

info

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.

info

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.

info

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.

info

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

  1. c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
  2. 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
  3. ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
  4. 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
  5. 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
  6. cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
  7. 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
  8. ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
  9. 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
  10. 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
  11. 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
  12. 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
  13. 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
  14. d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
  15. 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
  16. 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
  17. 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
  18. b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
  19. 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
  20. 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
  21. 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
  22. 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
  23. a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22current
  24. 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
  25. 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
  26. 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
  27. 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
  28. fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
  29. d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
  30. c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
  31. 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
  32. 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
  33. 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
  34. 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
  35. fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
  36. 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08