Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/app/(mobile-ui)/dev/journey/EmailCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

import { JOURNEY_API_BASE } from './journeyData'
import type { SpecEmailStep } from './journeyTypes'

/**
* Compact card for one lifecycle email step. Links to the API's live rendered
* preview (opens in a new tab against the sandbox API).
*/
export default function EmailCard({ step }: { step: SpecEmailStep }) {
return (
<a
href={`${JOURNEY_API_BASE}/__dev/email-preview/${step.type}?example=0`}
target="_blank"
rel="noreferrer"
className="block rounded-sm border border-n-1 bg-white p-2.5 hover:bg-primary-3/30"
>
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-bold leading-tight">{step.subject}</div>
{typeof step.afterDaysStuck === 'number' && (
<span className="shrink-0 rounded-sm border border-n-1 bg-yellow-1 px-1 py-0.5 text-[9px] font-bold">
day {step.afterDaysStuck} stuck
</span>
)}
</div>
<p className="mt-1 text-[11px] leading-snug text-grey-1">{step.preview}</p>
<p className="mt-1 text-[11px] leading-snug">
<span className="rounded-sm bg-yellow-1 px-1 font-bold">{step.ctaText}</span>
<span className="text-grey-1"> → {step.ctaPath}</span>
</p>
<p className="mt-1.5 font-mono text-[9px] leading-tight text-grey-1">{step.type} · preview ↗</p>
</a>
)
}
27 changes: 27 additions & 0 deletions src/app/(mobile-ui)/dev/journey/FindingsStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client'

import { FINDINGS } from './journeyData'

/**
* The inventory's product-issue findings as collapsible warning cards —
* real gaps in the activation journey, kept visible next to the board.
*/
export default function FindingsStrip() {
return (
<div className="flex flex-col gap-2">
{FINDINGS.map((finding) => (
<details key={finding.id} className="rounded-sm border border-n-1 bg-yellow-1/40">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-bold">
⚠️ {finding.id}. {finding.title}
</summary>
<div className="border-t border-n-1 bg-white px-3 py-2">
<p className="text-sm">{finding.detail}</p>
<p className="mt-1.5 break-all font-mono text-[10px] leading-relaxed text-grey-1">
{finding.sourceFiles.join(' · ')}
</p>
</div>
</details>
))}
</div>
)
}
115 changes: 115 additions & 0 deletions src/app/(mobile-ui)/dev/journey/JourneyBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
'use client'

import { FUNNEL_STATES, IN_APP_SURFACES } from './journeyData'
import type { JourneySpec } from './journeyTypes'
import SurfaceCard from './SurfaceCard'
import EmailCard from './EmailCard'

function GroupHeader({ emoji, label }: { emoji: string; label: string }) {
return (
<div className="mt-1 text-[10px] font-bold uppercase tracking-wide text-grey-1">
{emoji} {label}
</div>
)
}

/**
* The column-per-funnel-state board: in-app surfaces (static catalog from
* journeyData) + emails/push (live from the API spec) stacked per state.
*/
export default function JourneyBoard({ spec, specError }: { spec: JourneySpec | null; specError: string | null }) {
const mappedStageNames = new Set(FUNNEL_STATES.flatMap((s) => s.specStages))
const unmappedStages = spec ? spec.stages.filter((st) => !mappedStageNames.has(st.stage)) : []

return (
<div className="overflow-x-auto pb-2">
<div className="flex w-max gap-3">
{FUNNEL_STATES.map((state, i) => {
const surfaces = IN_APP_SURFACES.filter((s) => s.states.includes(state.id))
const stages = spec ? spec.stages.filter((st) => state.specStages.includes(st.stage)) : []
return (
<div key={state.id} className="w-72 shrink-0 rounded-sm border border-n-1 bg-primary-3/40">
<div className="border-b border-n-1 bg-white p-2.5">
<div className="text-sm font-bold">
{i + 1}. {state.label}
</div>
<p className="mt-0.5 text-[11px] leading-snug text-grey-1">{state.description}</p>
</div>
<div className="flex flex-col gap-1.5 p-2">
<GroupHeader emoji="🏠" label="in-app" />
{surfaces.map((surface) => (
<SurfaceCard key={surface.id} surface={surface} />
))}
{surfaces.length === 0 && (
<p className="text-[11px] italic text-grey-1">No activation-specific surface.</p>
)}

<GroupHeader emoji="✉️" label="emails" />
{specError && <p className="text-[11px] italic text-grey-1">{specError}</p>}
{spec && state.includesWelcome && (
<>
<p className="text-[10px] italic leading-snug text-grey-1">
On signup (immediate):
</p>
<EmailCard step={spec.welcome} />
</>
)}
{stages.map((stage) => (
<div key={stage.stage} className="flex flex-col gap-1.5">
<p className="text-[10px] italic leading-snug text-grey-1">
stage <span className="font-mono font-bold">{stage.stage}</span> —{' '}
{stage.predicate}
</p>
{stage.steps.map((step) => (
<EmailCard key={step.type} step={step} />
))}
</div>
))}
{spec && stages.length === 0 && !state.includesWelcome && (
<p className="text-[11px] italic text-grey-1">
{state.noEmailReason ?? 'No lifecycle email in this state.'}
</p>
)}

<GroupHeader emoji="📳" label="push" />
{specError && <p className="text-[11px] italic text-grey-1">{specError}</p>}
{spec &&
(state.includesPushReminder ? (
spec.pushReminders.map((push) => (
<div
key={push.type}
className="rounded-sm border border-n-1 bg-white p-2.5"
>
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-bold leading-tight">{push.title}</div>
<span className="shrink-0 rounded-sm border border-n-1 bg-yellow-1 px-1 py-0.5 text-[9px] font-bold">
after {push.afterMinutes}min
</span>
</div>
<p className="mt-1 text-[10px] leading-snug text-grey-1">{push.note}</p>
<p className="mt-1.5 font-mono text-[9px] leading-tight text-grey-1">
{push.type} · {push.channels.join(' + ')}
</p>
</div>
))
) : (
<p className="text-[11px] italic text-grey-1">—</p>
))}
</div>
</div>
)
})}
{unmappedStages.length > 0 && (
<div className="w-72 shrink-0 rounded-sm border border-n-1 bg-yellow-1/40 p-2.5">
<div className="text-sm font-bold">Unmapped spec stages</div>
<p className="mt-1 text-[11px] text-grey-1">
The API spec reports stages this board doesn&apos;t map to a column yet — update
FUNNEL_STATES.specStages in journeyData.ts:
</p>
<p className="mt-1 font-mono text-[11px]">{unmappedStages.map((s) => s.stage).join(', ')}</p>
</div>
)}
</div>
</div>
)
}
34 changes: 34 additions & 0 deletions src/app/(mobile-ui)/dev/journey/RulesLegend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

import type { SpecRules } from './journeyTypes'

function Chip({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1.5 rounded-sm border border-n-1 bg-white px-2 py-1">
<span className="text-[10px] font-bold uppercase tracking-wide text-grey-1">{label}</span>
<span className="text-xs font-bold">{value}</span>
</div>
)
}

/** Compact legend strip for the email machine's global rules (from spec.rules). */
export default function RulesLegend({ rules, specError }: { rules: SpecRules | null; specError: string | null }) {
if (!rules) {
return (
<div className="rounded-sm border border-n-1 bg-yellow-1/40 p-3 text-sm">
{specError ?? 'Loading email-machine rules…'}
</div>
)
}
return (
<div className="flex flex-wrap gap-2">
<Chip label="nudge 1" value={`day ${rules.step1AfterDays} stuck`} />
<Chip label="nudge 2" value={`day ${rules.step2AfterDays} stuck`} />
<Chip label="governor" value={`≥${rules.governorDays}d between emails`} />
<Chip label="freshness" value={`${rules.freshnessDays}d window`} />
<Chip label="holdout" value={`${Math.round(rules.holdoutFraction * 100)}% control`} />
<Chip label="send window" value={`${rules.sendWindowUtc.startHour}–${rules.sendWindowUtc.endHour}h UTC`} />
<Chip label="cap" value={`${rules.maxSendsPerCycle}/cycle`} />
</div>
)
}
41 changes: 41 additions & 0 deletions src/app/(mobile-ui)/dev/journey/SurfaceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client'

import type { InAppSurface } from './journeyTypes'

const KIND_LABEL: Record<InAppSurface['kind'], string> = {
step: 'home step',
carousel: 'carousel',
modal: 'modal',
'card-screen': '/card',
}

/** Compact card for one in-app surface inside a journey-board column. */
export default function SurfaceCard({ surface }: { surface: InAppSurface }) {
return (
<div className="rounded-sm border border-n-1 bg-white p-2.5">
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-bold leading-tight">{surface.name}</div>
<div className="flex shrink-0 gap-1">
{surface.isNewInThisPr && (
<span className="rounded-sm border border-n-1 bg-green-1 px-1 py-0.5 text-[9px] font-bold uppercase">
NEW in this PR
</span>
)}
<span className="rounded-sm border border-n-1 bg-primary-3 px-1 py-0.5 text-[9px] font-bold uppercase">
{KIND_LABEL[surface.kind]}
</span>
</div>
</div>
<p className="mt-1 text-[11px] leading-snug text-n-1">{surface.copy}</p>
{surface.cta && (
<p className="mt-1 text-[11px] leading-snug">
<span className="rounded-sm bg-yellow-1 px-1 font-bold">{surface.cta.label}</span>
<span className="text-grey-1"> → {surface.cta.dest}</span>
</p>
)}
<p className="mt-1 text-[10px] italic leading-snug text-grey-1">{surface.condition}</p>
{surface.note && <p className="mt-1 text-[10px] leading-snug text-grey-1">{surface.note}</p>}
<p className="mt-1.5 break-all font-mono text-[9px] leading-tight text-grey-1">{surface.sourceFile}</p>
</div>
)
}
122 changes: 122 additions & 0 deletions src/app/(mobile-ui)/dev/journey/UserInspector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client'

import { useState } from 'react'
import { Button } from '@/components/0_Bruddle/Button'
import { JOURNEY_API_BASE } from './journeyData'
import type { JourneyInspectResponse } from './journeyTypes'

/**
* Live user lookup: which lifecycle nudge is due for this user right now, and
* what have they already received (GET /__dev/journey-inspect?userId=…).
*/
export default function UserInspector() {
const [userId, setUserId] = useState('')
const [result, setResult] = useState<JourneyInspectResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

const inspect = async () => {
const id = userId.trim()
if (!id) return
setLoading(true)
setError(null)
setResult(null)
try {
const res = await fetch(`${JOURNEY_API_BASE}/__dev/journey-inspect?userId=${encodeURIComponent(id)}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setResult((await res.json()) as JourneyInspectResponse)
} catch {
setError('Inspect unavailable — is the sandbox API running with PR #1234 code (DEV_EMAIL_PREVIEW=true)?')
} finally {
setLoading(false)
}
}

const dueLabel = (() => {
if (!result) return null
if (!result.due) return 'none due (graduated, not in audience, or up to date)'
if (result.due.skip === 'holdout') return `${result.due.type} — HELD (holdout control group)`
if (result.due.skip === 'governor') return `${result.due.type} — HELD (governor: too soon after last email)`
return `${result.due.type} — due now${result.due.hasPendingRewards ? ' (rewards variant)' : ''}`
})()

return (
<div className="flex max-w-2xl flex-col gap-3">
<div className="flex gap-2">
<input
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void inspect()
}}
placeholder="userId (uuid)"
className="h-10 flex-1 rounded-sm border border-n-1 px-3 font-mono text-sm outline-none"
/>
<Button variant="purple" shadowSize="4" className="w-auto px-6" onClick={() => void inspect()}>
{loading ? 'Looking…' : 'Inspect'}
</Button>
</div>

{error && <div className="rounded-sm border border-n-1 bg-yellow-1/40 p-3 text-sm">{error}</div>}

{result && !result.user && (
<div className="rounded-sm border border-n-1 bg-yellow-1/40 p-3 text-sm">User not found.</div>
)}

{result?.user && (
<div className="rounded-sm border border-n-1 bg-white p-3">
<div className="text-sm font-bold">
{result.user.username ?? '(no username)'}{' '}
<span className="font-normal text-grey-1">{result.user.email ?? '(no email)'}</span>
</div>
<p className="mt-0.5 text-xs text-grey-1">
signed up {new Date(result.user.createdAt).toLocaleDateString()} · card access{' '}
{result.user.cardAccessGrantedAt
? new Date(result.user.cardAccessGrantedAt).toLocaleDateString()
: 'not granted'}
</p>
<p className="mt-2 text-sm">
<span className="font-bold">Current nudge:</span>{' '}
<span className="rounded-sm bg-primary-3 px-1.5 py-0.5 font-mono text-xs">{dueLabel}</span>
</p>

<div className="mt-3">
<div className="text-xs font-bold uppercase tracking-wide text-grey-1">
Lifecycle email history
</div>
{result.history.length === 0 ? (
<p className="mt-1 text-sm text-grey-1">Nothing sent or attempted yet.</p>
) : (
<div className="mt-1 overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-n-1 text-grey-1">
<th className="py-1 pr-3 font-bold">event</th>
<th className="py-1 pr-3 font-bold">channel</th>
<th className="py-1 pr-3 font-bold">status</th>
<th className="py-1 pr-3 font-bold">skip</th>
<th className="py-1 font-bold">sent</th>
</tr>
</thead>
<tbody>
{result.history.map((row, i) => (
<tr key={i} className="border-b border-n-1/20">
<td className="py-1 pr-3 font-mono">{row.eventType}</td>
<td className="py-1 pr-3">{row.channel}</td>
<td className="py-1 pr-3">{row.status}</td>
<td className="py-1 pr-3">{row.skipReason ?? '—'}</td>
<td className="py-1">
{row.sentAt ? new Date(row.sentAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
)
}
Loading
Loading