diff --git a/README.md b/README.md index 7265244..bf2bf5e 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ Two orthogonal concepts: **`type`** = how it renders, **`category`** = where it |---|---|---| | `error` | `errors` | β†’ Create Issue / + Auto-Fix buttons (when the project has a repo) | | `seo_report` | `seo` | search/metrics digest | -| `signup` | `users` | | -| `subscription` | `revenue` | `kind`: new / upgrade / downgrade / cancel / expired / payment_failed / refund / addon; `endsAt`, `source`, `variant`, `subscriptionId` | -| `feedback` | `feedback` | bug/question/feature/general; breadcrumb, steps, browser/viewport meta | +| `signup` | `users` | `kind`: signup / waitlist (waitlist renders with a πŸ“ + "Waitlist signup" title) | +| `subscription` | `revenue` | `kind`: new / upgrade / downgrade / cancel / expired / payment_failed / refund / addon / addon_cancel; `endsAt`, `source`, `variant`, `subscriptionId` | +| `feedback` | `feedback` | bug/question/feature/general; breadcrumb, steps, browser/viewport meta, `consoleErrors[]` (rendered as a Console errors sub-section + included in filed issues) | | `cron` | `ops` | run summaries; supports a monospace `table` | | `infra` | `ops` | homelab/infra alerts | | `booking` | `bookings` | action card with Email / Add-to-Calendar / Reschedule URL buttons | diff --git a/app/(console)/previews/actions.ts b/app/(console)/previews/actions.ts index a9231f4..9b4aa43 100644 --- a/app/(console)/previews/actions.ts +++ b/app/(console)/previews/actions.ts @@ -4,10 +4,10 @@ import { asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { projects } from '@/lib/db/schema'; -import { parseEvent, type EventType } from '@/lib/events/schemas'; +import { parseEvent } from '@/lib/events/schemas'; import { ingestEvent } from '@/lib/ingest'; import type { AuthedProject } from '@/lib/auth'; -import { SAMPLE_TYPES, SAMPLES } from '@/lib/render/samples'; +import { SAMPLE_ENTRIES, sampleById, type SampleId } from '@/lib/render/samples'; export type SendPreviewResult = { ok: true; status: string } | { error: string }; @@ -35,12 +35,12 @@ async function resolveProject(slug?: string): Promise { - const sample = SAMPLES[type]; - if (sample === undefined) return { error: `No sample for type "${type}".` }; +/** Post a single sample event (addressed by its stable id) through the real ingest pipeline. */ +export async function sendPreview(sampleId: SampleId, projectSlug?: string): Promise { + const sample = sampleById(sampleId); + if (!sample) return { error: `No sample with id "${sampleId}".` }; - const parsed = parseEvent(sample); + const parsed = parseEvent(sample.envelope); if (!parsed.success) return { error: `Invalid sample: ${parsed.error}` }; const project = await resolveProject(projectSlug); @@ -56,35 +56,31 @@ export async function sendPreview(type: EventType, projectSlug?: string): Promis export type FireAllResult = { project: string; - results: { type: EventType; status: string; error?: string }[]; + results: { id: SampleId; label: string; status: string; error?: string }[]; }; /** - * Fire one of every event type sequentially. Sequential (not parallel) so the - * order in Slack is deterministic and we stay well under any rate limits. - * Returns a per-type result list for the UI to display. + * Fire every sample (one per type, plus variant kinds) sequentially through the + * real ingest pipeline. Sequential (not parallel) so the order in Slack is + * deterministic and we stay well under any rate limit. Returns a per-sample + * result list for the UI to display. */ export async function fireAllPreviews(projectSlug?: string): Promise { const project = await resolveProject(projectSlug); if ('error' in project) return project; const results: FireAllResult['results'] = []; - for (const type of SAMPLE_TYPES) { - const sample = SAMPLES[type]; - if (sample === undefined) { - results.push({ type, status: 'skipped', error: 'no sample defined' }); - continue; - } - const parsed = parseEvent(sample); + for (const sample of SAMPLE_ENTRIES) { + const parsed = parseEvent(sample.envelope); if (!parsed.success) { - results.push({ type, status: 'failed', error: parsed.error }); + results.push({ id: sample.id, label: sample.label, status: 'failed', error: parsed.error }); continue; } try { const r = await ingestEvent(project, parsed.data); - results.push({ type, status: r.status }); + results.push({ id: sample.id, label: sample.label, status: r.status }); } catch (err) { - results.push({ type, status: 'failed', error: err instanceof Error ? err.message : String(err) }); + results.push({ id: sample.id, label: sample.label, status: 'failed', error: err instanceof Error ? err.message : String(err) }); } } return { project: project.slug, results }; diff --git a/app/(console)/previews/fire-all-button.tsx b/app/(console)/previews/fire-all-button.tsx index 36283cd..e90187e 100644 --- a/app/(console)/previews/fire-all-button.tsx +++ b/app/(console)/previews/fire-all-button.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { Flame } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { SAMPLE_ENTRIES } from '@/lib/render/samples'; import { fireAllPreviews, type FireAllResult } from './actions'; /** @@ -28,7 +29,7 @@ export function FireAllButton({ projectSlug }: { projectSlug: string }) {
{result && 'error' in result ? ( @@ -46,11 +47,11 @@ export function FireAllButton({ projectSlug }: { projectSlug: string }) { : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'; return ( - {r.type} + {r.label} {failed ? 'fail' : r.status} ); diff --git a/app/(console)/previews/page.tsx b/app/(console)/previews/page.tsx index 2e0e707..c9c7512 100644 --- a/app/(console)/previews/page.tsx +++ b/app/(console)/previews/page.tsx @@ -4,11 +4,11 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { db } from '@/lib/db'; import { projects } from '@/lib/db/schema'; -import { parseEvent, type EventType } from '@/lib/events/schemas'; +import { parseEvent } from '@/lib/events/schemas'; import { renderEvent } from '@/lib/render'; import type { SlackBlock } from '@/lib/render/blocks'; import { mrkdwnToHtml } from '@/lib/render/mrkdwn-to-html'; -import { SAMPLES, SAMPLE_TYPES } from '@/lib/render/samples'; +import { SAMPLE_ENTRIES, type Sample } from '@/lib/render/samples'; import { FireAllButton } from './fire-all-button'; import { ProjectPicker } from './project-picker'; import { SendLiveButton } from './send-button'; @@ -80,15 +80,14 @@ function ActionsBlock({ block }: { block: SlackBlock }) { ); } -function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: string }) { - const sample = SAMPLES[type]; - const parsed = parseEvent(sample); +function PreviewCard({ entry, previewSlug }: { entry: Sample; previewSlug: string }) { + const parsed = parseEvent(entry.envelope); if (!parsed.success) { return ( - {type} + {entry.label} parse error @@ -103,7 +102,7 @@ function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: stri githubRepo: 'mattdecrevel/example', autofixEnabled: true, }); - const isRaw = type === 'raw'; + const isRaw = entry.type === 'raw'; // Split body section(s) from the trailing context footer (house-style only). const footer = !isRaw && blocks.at(-1)?.type === 'context' ? blockText(blocks.at(-1)!) : null; @@ -113,10 +112,10 @@ function PreviewCard({ type, previewSlug }: { type: EventType; previewSlug: stri
- {type} + {entry.label} {isRaw ? exempt : null}
- +
@@ -197,10 +196,10 @@ export default async function PreviewsPage({ searchParams }: PageProps) {

Previews

- Every message type rendered in the “Rich hybrid” house style. “Send live” posts the real - sample through the selected project's pipeline; “Fire all 11 types” sends one of each in - order so you can verify routing + rendering end-to-end. Dashed pills are server-handled - (action_id) buttons. + Every message type β€” plus key variant kinds (waitlist signup, add-on cancel) β€” rendered in + the “Rich hybrid” house style. “Send live” posts the real sample through the + selected project's pipeline; “Fire all” sends one of each in order so you can verify + routing + rendering end-to-end. Dashed pills are server-handled (action_id) buttons.

@@ -221,8 +220,8 @@ export default async function PreviewsPage({ searchParams }: PageProps) {
- {SAMPLE_TYPES.map((type) => ( - + {SAMPLE_ENTRIES.map((entry) => ( + ))}
diff --git a/app/(console)/previews/send-button.tsx b/app/(console)/previews/send-button.tsx index 7ea46ee..ad7a8bd 100644 --- a/app/(console)/previews/send-button.tsx +++ b/app/(console)/previews/send-button.tsx @@ -4,17 +4,17 @@ import * as React from 'react'; import { Send } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import type { EventType } from '@/lib/events/schemas'; +import type { SampleId } from '@/lib/render/samples'; import { sendPreview } from './actions'; -export function SendLiveButton({ type, projectSlug }: { type: EventType; projectSlug: string }) { +export function SendLiveButton({ sampleId, projectSlug }: { sampleId: SampleId; projectSlug: string }) { const [pending, setPending] = React.useState(false); const [status, setStatus] = React.useState<{ kind: 'ok' | 'error'; text: string } | null>(null); async function onClick() { setPending(true); setStatus(null); - const result = await sendPreview(type, projectSlug); + const result = await sendPreview(sampleId, projectSlug); setPending(false); if ('error' in result) { setStatus({ kind: 'error', text: result.error }); diff --git a/lib/events/schemas.ts b/lib/events/schemas.ts index c5f0bee..334dae7 100644 --- a/lib/events/schemas.ts +++ b/lib/events/schemas.ts @@ -23,8 +23,8 @@ const linkSchema = z.object({ label: z.string(), url: z.string() }); const payloads = { error: z.object({ message: z.string(), route: z.string().optional(), stack: z.string().optional(), source: z.string().optional() }), seo_report: z.object({ siteLabel: z.string(), clicks: z.number(), impressions: z.number(), topQueries: z.array(z.string()).optional(), subSections: z.array(subSectionSchema).optional() }), - signup: z.object({ email: z.string(), name: z.string().optional() }), - subscription: z.object({ email: z.string(), kind: z.enum(['new', 'upgrade', 'downgrade', 'cancel', 'expired', 'payment_failed', 'refund', 'addon']), plan: z.string().optional(), amount: z.number().optional(), interval: z.string().optional(), endsAt: z.string().optional(), source: z.string().optional(), variant: z.string().optional(), subscriptionId: z.string().optional() }), + signup: z.object({ email: z.string(), name: z.string().optional(), kind: z.enum(['signup', 'waitlist']).optional() }), + subscription: z.object({ email: z.string(), kind: z.enum(['new', 'upgrade', 'downgrade', 'cancel', 'expired', 'payment_failed', 'refund', 'addon', 'addon_cancel']), plan: z.string().optional(), amount: z.number().optional(), interval: z.string().optional(), endsAt: z.string().optional(), source: z.string().optional(), variant: z.string().optional(), subscriptionId: z.string().optional() }), feedback: z.object({ category: z.enum(['bug', 'question', 'feature', 'general']), message: z.string(), @@ -41,6 +41,7 @@ const payloads = { locale: z.string().optional(), timezone: z.string().optional(), pageUrl: z.string().optional(), + consoleErrors: z.array(z.string()).optional(), }), cron: z.object({ name: z.string(), ok: z.boolean(), summary: z.string().optional(), table: tableSchema.optional(), bullets: z.array(z.string()).optional() }), infra: z.object({ host: z.string(), message: z.string(), metric: z.string().optional() }), diff --git a/lib/github.ts b/lib/github.ts index 329149b..ab342a5 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -31,6 +31,11 @@ export function buildIssue(type: string, payload: Record): Issu lines.push('', '**Steps:**'); (p.steps as unknown[]).forEach((s, i) => lines.push(`${i + 1}. ${String(s)}`)); } + if (Array.isArray(p.consoleErrors) && p.consoleErrors.length) { + lines.push('', '**Console errors:**', '```'); + (p.consoleErrors as unknown[]).slice(-10).forEach((e) => lines.push(String(e))); + lines.push('```'); + } const meta: string[] = []; if (p.userEmail) meta.push(`From: ${String(p.userEmail)}`); const page = p.page ?? p.pageUrl; diff --git a/lib/render/index.ts b/lib/render/index.ts index 602db67..3e78261 100644 --- a/lib/render/index.ts +++ b/lib/render/index.ts @@ -10,8 +10,8 @@ function pickEmoji(ev: ParsedEvent): string { switch (ev.type) { case 'error': return '🚨'; case 'feedback': return ({ bug: 'πŸ›', question: '❓', feature: 'πŸ’‘', general: 'πŸ’¬' } as Record)[p.category] ?? 'πŸ’¬'; - case 'subscription': return ({ new: 'πŸŽ‰', upgrade: '⬆️', downgrade: '⬇️', cancel: 'πŸ‘‹', expired: 'βŒ›', payment_failed: '⚠️', refund: 'πŸ’Έ', addon: 'βž•' } as Record)[p.kind] ?? 'πŸ’³'; - case 'signup': return 'πŸ‘€'; + case 'subscription': return ({ new: 'πŸŽ‰', upgrade: '⬆️', downgrade: '⬇️', cancel: 'πŸ‘‹', expired: 'βŒ›', payment_failed: '⚠️', refund: 'πŸ’Έ', addon: 'βž•', addon_cancel: 'βž–' } as Record)[p.kind] ?? 'πŸ’³'; + case 'signup': return p.kind === 'waitlist' ? 'πŸ“' : 'πŸ‘€'; case 'cron': return p.ok ? 'βœ…' : '⚠️'; case 'infra': return ev.severity === 'error' ? 'πŸ”΄' : 'πŸ“‘'; case 'booking': return 'πŸ“…'; @@ -108,11 +108,17 @@ export function renderEvent(ev: ParsedEvent, ctx?: RenderContext): Rendered { ...linkActions, ]; const hasActions = feedbackIssueActions.length || feedbackUrlActions.length; + // Captured console errors render as a dedicated sub-section (newest last, + // capped) so the report carries the failing client context with it. + const consoleErrs = Array.isArray(p.consoleErrors) ? p.consoleErrors.filter(Boolean) : []; + const feedbackSubSections = consoleErrs.length + ? [{ header: 'Console errors', lines: consoleErrs.slice(-10).map((e: string) => `\`${String(e)}\``) }] + : undefined; return { text: `Feedback β€” ${p.category}`, blocks: richMessage({ ...base, emoji, title, subject, breadcrumb: p.breadcrumb, - body: p.message, steps: p.steps, meta, + body: p.message, steps: p.steps, subSections: feedbackSubSections, meta, ...(hasActions ? { interactiveActions: feedbackIssueActions, actions: feedbackUrlActions, divider: true } : {}), }), }; @@ -120,10 +126,12 @@ export function renderEvent(ev: ParsedEvent, ctx?: RenderContext): Rendered { case 'signup': { const identity = p.name ? `${p.name} (${p.email})` : p.email; + const isWaitlist = p.kind === 'waitlist'; + const title = isWaitlist ? 'Waitlist signup' : 'New signup'; return { - text: `New signup β€” ${identity}`, + text: `${isWaitlist ? 'New waitlist signup' : 'New signup'} β€” ${identity}`, blocks: richMessage({ - ...base, emoji, title: 'New signup', subject: identity, + ...base, emoji, title, subject: identity, }), }; } diff --git a/lib/render/samples.ts b/lib/render/samples.ts index 8178d86..d4d388e 100644 --- a/lib/render/samples.ts +++ b/lib/render/samples.ts @@ -1,143 +1,243 @@ import type { EventType } from '@/lib/events/schemas'; -/** Ordered list of message types shown on the Previews page. */ -export const SAMPLE_TYPES: EventType[] = [ - 'signup', 'subscription', 'cron', 'seo_report', 'booking', - 'error', 'feedback', 'infra', 'contact', 'generic', 'raw', -]; +export interface SampleEntry { + /** Stable id used by the preview send actions. Equals `type` for the canonical sample of a type. */ + id: string; + /** Short human label shown on the preview card / fire-all chip. */ + label: string; + /** The underlying event type (drives the raw-passthrough badge + parse). */ + type: EventType; + /** Full ingest envelope (`type` + `payload`), ready to feed through parseEvent / ingestEvent. */ + envelope: unknown; +} /** - * One realistic sample event envelope per type. Each is a full ingest payload - * (envelope with `type` + `payload`), ready to feed through parseEvent / ingestEvent. + * One realistic sample per type, plus a handful of variant kinds that share a + * type but render differently (e.g. a waitlist signup, an add-on cancellation). + * Ordered for the Previews page + the "fire all" sequence. Canonical samples use + * `id === type`; variants get a `-` id so the send actions can address + * them individually. */ -export const SAMPLES: Record = { - signup: { +export const SAMPLE_ENTRIES = [ + { + id: 'signup', + label: 'signup', type: 'signup', - payload: { email: 'ada@example.com', name: 'Ada Lovelace' }, + envelope: { + type: 'signup', + payload: { email: 'ada@example.com', name: 'Ada Lovelace' }, + }, + }, + { + id: 'signup-waitlist', + label: 'signup Β· waitlist', + type: 'signup', + envelope: { + type: 'signup', + payload: { email: 'grace@example.com', name: 'Grace Hopper', kind: 'waitlist' }, + }, + }, + { + id: 'subscription', + label: 'subscription Β· downgrade', + type: 'subscription', + envelope: { + type: 'subscription', + payload: { email: 'ada@example.com', kind: 'downgrade', plan: 'Starter', amount: 9, interval: 'mo', endsAt: 'Jun 30, 2026' }, + }, }, - subscription: { + { + id: 'subscription-addon-cancel', + label: 'subscription Β· addon cancel', type: 'subscription', - payload: { email: 'ada@example.com', kind: 'downgrade', plan: 'Starter', amount: 9, interval: 'mo', endsAt: 'Jun 30, 2026' }, + envelope: { + type: 'subscription', + payload: { email: 'ada@example.com', kind: 'addon_cancel', plan: 'Extra search runs', source: 'lemon' }, + }, }, - cron: { + { + id: 'cron', + label: 'cron', type: 'cron', - payload: { - name: 'picks-nightly', - ok: true, - summary: 'Scored 38 options, surfaced the top 5 by expected value.', - table: { - columns: ['Ticker', 'Strike', 'EV', 'Conf'], - rows: [ - ['NVDA', '$120c', '+18.4%', '0.82'], - ['AAPL', '$210c', '+9.1%', '0.74'], - ['TSLA', '$260p', '+7.6%', '0.69'], - ], + envelope: { + type: 'cron', + payload: { + name: 'picks-nightly', + ok: true, + summary: 'Scored 38 options, surfaced the top 5 by expected value.', + table: { + columns: ['Ticker', 'Strike', 'EV', 'Conf'], + rows: [ + ['NVDA', '$120c', '+18.4%', '0.82'], + ['AAPL', '$210c', '+9.1%', '0.74'], + ['TSLA', '$260p', '+7.6%', '0.69'], + ], + }, }, }, }, - seo_report: { + { + id: 'seo_report', + label: 'seo_report', type: 'seo_report', - footerNote: 'Anthropic budget: $2.43 of $25.00 this month', - payload: { - siteLabel: 'decrevel.dev', - clicks: 312, - impressions: 8420, - topQueries: ['matt decrevel', 'agentic workflows', 'next.js notification service'], - subSections: [ - { header: 'Top movers', lines: ['β€’ "loop notifications" +42 clicks', 'β€’ "drizzle neon" +18 clicks'] }, - { header: 'Health', lines: ['β€’ 0 crawl errors', 'β€’ 3 new pages indexed'] }, - ], + envelope: { + type: 'seo_report', + footerNote: 'Anthropic budget: $2.43 of $25.00 this month', + payload: { + siteLabel: 'decrevel.dev', + clicks: 312, + impressions: 8420, + topQueries: ['matt decrevel', 'agentic workflows', 'next.js notification service'], + subSections: [ + { header: 'Top movers', lines: ['β€’ "loop notifications" +42 clicks', 'β€’ "drizzle neon" +18 clicks'] }, + { header: 'Health', lines: ['β€’ 0 crawl errors', 'β€’ 3 new pages indexed'] }, + ], + }, }, }, - booking: { + { + id: 'booking', + label: 'booking', type: 'booking', - payload: { - name: 'Sam Rivera', - email: 'sam@example.com', - start: 'May 30, 2026 Β· 2:00 PM ET', - notes: 'Wants to talk through a platform migration.', - startIso: '2026-05-30T18:00:00Z', - endIso: '2026-05-30T18:30:00Z', - location: 'Google Meet', - meetingUrl: 'https://meet.google.com/abc-defg-hij', - manageUrl: 'https://cal.com/booking/abc123', + envelope: { + type: 'booking', + payload: { + name: 'Sam Rivera', + email: 'sam@example.com', + start: 'May 30, 2026 Β· 2:00 PM ET', + notes: 'Wants to talk through a platform migration.', + startIso: '2026-05-30T18:00:00Z', + endIso: '2026-05-30T18:30:00Z', + location: 'Google Meet', + meetingUrl: 'https://meet.google.com/abc-defg-hij', + manageUrl: 'https://cal.com/booking/abc123', + }, }, }, - error: { + { + id: 'error', + label: 'error', type: 'error', - links: [{ label: 'View in Sentry', url: 'https://sentry.io/organizations/decrevel/issues/12345/' }], - payload: { - message: 'Cannot read properties of undefined (reading "id")', - route: '/api/export', - source: 'export-button', - stack: [ - 'TypeError: Cannot read properties of undefined (reading "id")', - ' at handleExport (app/components/export-button.tsx:42:18)', - ' at onClick (app/components/export-button.tsx:71:7)', - ' at HTMLButtonElement.callCallback (react-dom.js:188:14)', - ].join('\n'), + envelope: { + type: 'error', + links: [{ label: 'View in Sentry', url: 'https://sentry.io/organizations/decrevel/issues/12345/' }], + payload: { + message: 'Cannot read properties of undefined (reading "id")', + route: '/api/export', + source: 'export-button', + stack: [ + 'TypeError: Cannot read properties of undefined (reading "id")', + ' at handleExport (app/components/export-button.tsx:42:18)', + ' at onClick (app/components/export-button.tsx:71:7)', + ' at HTMLButtonElement.callCallback (react-dom.js:188:14)', + ].join('\n'), + }, }, }, - feedback: { + { + id: 'feedback', + label: 'feedback Β· bug', type: 'feedback', - payload: { - category: 'bug', - message: 'The dark-mode toggle resets on every page navigation.', - userEmail: 'user@example.com', - name: 'Elio Vance', - section: 'Settings', - breadcrumb: 'Account > Preferences > Appearance', - steps: [ - 'Enable dark mode on the Settings page.', - 'Navigate to any other page.', - 'Observe the theme reverts to light.', - ], - plan: 'Pro', - page: '/settings', - browser: 'Chrome 124', - viewport: '1440x900', - screen: '2560x1440', - locale: 'en-US', - timezone: 'America/New_York', + envelope: { + type: 'feedback', + payload: { + category: 'bug', + message: 'The dark-mode toggle resets on every page navigation.', + userEmail: 'user@example.com', + name: 'Elio Vance', + section: 'Settings', + breadcrumb: 'Account > Preferences > Appearance', + steps: [ + 'Enable dark mode on the Settings page.', + 'Navigate to any other page.', + 'Observe the theme reverts to light.', + ], + plan: 'Pro', + page: '/settings', + browser: 'Chrome 124', + viewport: '1440x900', + screen: '2560x1440', + locale: 'en-US', + timezone: 'America/New_York', + consoleErrors: [ + 'TypeError: Cannot read properties of null (reading "theme") at applyTheme (theme.ts:18)', + 'Warning: useLayoutEffect does nothing on the server.', + ], + }, }, }, - infra: { + { + id: 'infra', + label: 'infra', type: 'infra', - payload: { host: 'db-primary', message: 'Disk usage is high.', metric: '92% of 100GB' }, + envelope: { + type: 'infra', + payload: { host: 'db-primary', message: 'Disk usage is high.', metric: '92% of 100GB' }, + }, }, - contact: { + { + id: 'contact', + label: 'contact', type: 'contact', - payload: { - name: 'Jordan Lee', - email: 'jordan@example.com', - message: 'Loved the article on resume generation β€” do you do consulting?', - source: 'contact-form', + envelope: { + type: 'contact', + payload: { + name: 'Jordan Lee', + email: 'jordan@example.com', + message: 'Loved the article on resume generation β€” do you do consulting?', + source: 'contact-form', + }, }, }, - generic: { + { + id: 'generic', + label: 'generic', type: 'generic', - category: 'ops', - payload: { - emoji: 'πŸš€', - title: 'Deploy succeeded', - body: 'Production deploy of decrevel.dev completed.', - fields: [ - { label: 'Commit', value: 'a0fdd48' }, - { label: 'Duration', value: '1m 12s' }, - ], - context: 'triggered by push to production', + envelope: { + type: 'generic', + category: 'ops', + payload: { + emoji: 'πŸš€', + title: 'Deploy succeeded', + body: 'Production deploy of decrevel.dev completed.', + fields: [ + { label: 'Commit', value: 'a0fdd48' }, + { label: 'Duration', value: '1m 12s' }, + ], + context: 'triggered by push to production', + }, }, }, - raw: { + { + id: 'raw', + label: 'raw', type: 'raw', - category: 'ops', - payload: { - text: 'A custom raw message (exempt from house style).', - blocks: [ - { type: 'section', text: { type: 'mrkdwn', text: '*Custom block* β€” passed through verbatim.' } }, - { type: 'divider' }, - { type: 'context', elements: [{ type: 'mrkdwn', text: 'no loop footer here' }] }, - ], + envelope: { + type: 'raw', + category: 'ops', + payload: { + text: 'A custom raw message (exempt from house style).', + blocks: [ + { type: 'section', text: { type: 'mrkdwn', text: '*Custom block* β€” passed through verbatim.' } }, + { type: 'divider' }, + { type: 'context', elements: [{ type: 'mrkdwn', text: 'no loop footer here' }] }, + ], + }, }, }, -}; +] as const satisfies readonly SampleEntry[]; + +/** A single sample entry (the precise element type, with literal `id`/`type`). */ +export type Sample = (typeof SAMPLE_ENTRIES)[number]; + +/** + * Union of every sample's `id` β€” derived from SAMPLE_ENTRIES so the list stays + * the single source of truth (same `as const` β†’ derived-union pattern as the + * LOOP_* tuples). Keeps the preview send actions typed rather than stringly-typed. + */ +export type SampleId = Sample['id']; + +/** Look up a sample entry by its stable id. */ +export function sampleById(id: SampleId): Sample | undefined { + return SAMPLE_ENTRIES.find((s) => s.id === id); +} diff --git a/packages/client/README.md b/packages/client/README.md index 60c00b0..df6aaf5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -50,13 +50,18 @@ takes the typed payload + optional `LoopExtras` (`severity`, `links`, `footerNot ```ts await loop.signup({ email: 'ada@example.com', name: 'Ada' }); +await loop.signup({ email, name, kind: 'waitlist' }); // waitlist join (renders distinctly) await loop.subscription({ email, kind: 'new', plan: 'Pro', amount: 29, interval: 'mo', source: 'lemon' }); -await loop.feedback({ category: 'bug', message: 'Toggle broken', userEmail, page }); +await loop.subscription({ email, kind: 'addon_cancel', plan: 'Extra seats' }); +await loop.feedback({ category: 'bug', message: 'Toggle broken', userEmail, page, consoleErrors }); await loop.cron({ name: 'nightly-sync', ok: true, summary: '42 records' }); await loop.booking({ name, email, start, startIso, location }); await loop.contact({ name, email, message, source: 'form' }); await loop.error(err, { route: '/api/checkout', links: [{ label: 'View in Sentry', url: sentryUrl }] }); +// A "free signup" is just a signup routed to the revenue channel β€” no special kind: +await loop.signup({ email, name }, { category: 'revenue' }); + // `generic` and `raw` require an explicit routing category: await loop.generic({ title: 'Deploy', body: 'shipped' }, 'ops'); ``` @@ -88,8 +93,8 @@ Every event also accepts these optional base fields: - `category` β€” override the default routing category - `links` β€” `{ label, url }[]`, rendered as URL buttons on the message (e.g. "View in Sentry") - `footerNote` β€” a short context line in the footer (e.g. a budget/spend figure) -- `digest` β€” fold routine `info` events into a rolling summary *(handler not yet active β€” see the service README roadmap)* -- `idempotencyKey` β€” recorded for dedup *(enforcement pending)* +- `digest` β€” fold routine `info` events into a rolling summary (the service batches them and posts a per-category digest on a schedule) +- `idempotencyKey` β€” server drops a repeat event with the same key for the project (deduped at ingest) ### Runtime tuples (for dashboards, dropdowns, filters) diff --git a/packages/client/package.json b/packages/client/package.json index 18f9b75..dfeb752 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@mattdecrevel/loop", - "version": "0.5.0", + "version": "0.6.0", "description": "Thin fail-open client for the Loop notification service. Typed Slack-event SDK for routing notifications through one Slack bot, with rich Block Kit rendering server-side.", "license": "MIT", "author": "Matt Decrevel ", diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index c7898cc..d5a7718 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -87,6 +87,8 @@ export interface ErrorPayload { export interface SignupPayload { email: string; name?: string; + /** Distinguishes a real account creation from a waitlist join. Defaults to 'signup'. */ + kind?: 'signup' | 'waitlist'; } // Common billing sources; the `(string & {})` keeps autocomplete for these // while still accepting any other provider without a package bump. @@ -94,7 +96,16 @@ export type LoopSubscriptionSource = 'lemon' | 'apple_iap' | 'stripe' | (string export interface SubscriptionPayload { email: string; - kind: 'new' | 'upgrade' | 'downgrade' | 'cancel' | 'expired' | 'payment_failed' | 'refund' | 'addon'; + kind: + | 'new' + | 'upgrade' + | 'downgrade' + | 'cancel' + | 'expired' + | 'payment_failed' + | 'refund' + | 'addon' + | 'addon_cancel'; plan?: string; amount?: number; interval?: string; @@ -119,6 +130,8 @@ export interface FeedbackPayload { locale?: string; timezone?: string; pageUrl?: string; + /** Recent client-side console errors captured at report time (newest last). */ + consoleErrors?: string[]; } export interface CronPayload { name: string; diff --git a/packages/client/test/typed-helpers.test.ts b/packages/client/test/typed-helpers.test.ts index 1f67fb5..8fc35f4 100644 --- a/packages/client/test/typed-helpers.test.ts +++ b/packages/client/test/typed-helpers.test.ts @@ -50,8 +50,15 @@ describe('Loop typed helpers', () => { }); }); + describe('signup', () => { + it('threads a waitlist kind through into the payload', async () => { + await loop.signup({ email: 'a@b.com', kind: 'waitlist' }); + expect(bodyOf(fetchSpy)).toEqual({ type: 'signup', payload: { email: 'a@b.com', kind: 'waitlist' } }); + }); + }); + describe('subscription', () => { - for (const kind of ['new', 'cancel', 'payment_failed', 'addon'] as const) { + for (const kind of ['new', 'cancel', 'payment_failed', 'addon', 'addon_cancel'] as const) { it(`sends type:"subscription" for kind="${kind}"`, async () => { await loop.subscription({ email: 'a@b.com', kind, plan: 'pro' }); expect(bodyOf(fetchSpy)).toEqual({ @@ -72,6 +79,13 @@ describe('Loop typed helpers', () => { }); }); } + it('threads captured consoleErrors through into the payload', async () => { + await loop.feedback({ category: 'bug', message: 'broke', consoleErrors: ['TypeError: x'] }); + expect(bodyOf(fetchSpy)).toEqual({ + type: 'feedback', + payload: { category: 'bug', message: 'broke', consoleErrors: ['TypeError: x'] }, + }); + }); }); describe('cron', () => { diff --git a/tests/github.test.ts b/tests/github.test.ts index 3909194..95d675d 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -20,6 +20,17 @@ describe('buildIssue', () => { expect(issue.body).toContain('click'); }); + it('includes captured console errors in a feedback issue body', () => { + const issue = buildIssue('feedback', { + category: 'bug', + message: 'toggle resets', + consoleErrors: ['TypeError: cannot read x', 'Warning: deprecated API'], + }); + expect(issue.body).toContain('Console errors'); + expect(issue.body).toContain('TypeError: cannot read x'); + expect(issue.body).toContain('Warning: deprecated API'); + }); + it('falls back to a generic issue for unknown types', () => { const issue = buildIssue('mystery', { title: 'Something', body: 'happened' }); expect(typeof issue.title).toBe('string'); diff --git a/tests/render.test.ts b/tests/render.test.ts index 392bd31..8c92858 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -312,3 +312,43 @@ describe('richMessage actions', () => { expect(dividerIdx).toBe(actionsIdx - 1); }); }); + +describe('waitlist signups', () => { + it('renders a waitlist signup with the πŸ“ emoji and a Waitlist title', () => { + const { blocks } = renderEvent({ type: 'signup', payload: { email: 'a@b.com', name: 'Ada', kind: 'waitlist' } } as any, { siteLabel: 'decrevel.dev' }); + const json = JSON.stringify(blocks); + expect(json).toContain('πŸ“'); + expect(json).toContain('Waitlist signup'); + expect(json).not.toContain('New signup'); + }); + it('keeps the πŸ‘€ emoji and "New signup" title for a normal signup', () => { + const { blocks } = renderEvent({ type: 'signup', payload: { email: 'a@b.com', kind: 'signup' } } as any); + const json = JSON.stringify(blocks); + expect(json).toContain('πŸ‘€'); + expect(json).toContain('New signup'); + }); +}); + +describe('addon_cancel subscription kind', () => { + it('renders addon_cancel with the βž– emoji', () => { + const { blocks } = renderEvent({ type: 'subscription', payload: { email: 'a@b.com', kind: 'addon_cancel' } } as any); + expect(JSON.stringify(blocks)).toContain('βž–'); + }); +}); + +describe('feedback console errors', () => { + it('renders captured console errors in a Console errors sub-section', () => { + const { blocks } = renderEvent({ + type: 'feedback', + payload: { category: 'bug', message: 'broke', consoleErrors: ['TypeError: cannot read x', 'Warning: deprecated'] }, + } as any); + const json = JSON.stringify(blocks); + expect(json).toContain('Console errors'); + expect(json).toContain('TypeError: cannot read x'); + expect(json).toContain('Warning: deprecated'); + }); + it('omits the Console errors sub-section when none are provided', () => { + const { blocks } = renderEvent({ type: 'feedback', payload: { category: 'bug', message: 'broke' } } as any); + expect(JSON.stringify(blocks)).not.toContain('Console errors'); + }); +}); diff --git a/tests/samples.test.ts b/tests/samples.test.ts new file mode 100644 index 0000000..fdbeda2 --- /dev/null +++ b/tests/samples.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { SAMPLE_ENTRIES, sampleById, type SampleId } from '@/lib/render/samples'; +import { parseEvent } from '@/lib/events/schemas'; +import { renderEvent } from '@/lib/render'; + +describe('preview samples', () => { + it('every sample envelope parses successfully', () => { + const failures = SAMPLE_ENTRIES.filter((s) => !parseEvent(s.envelope).success).map((s) => s.id); + expect(failures).toEqual([]); + }); + + it('every sample renders without throwing', () => { + for (const s of SAMPLE_ENTRIES) { + const parsed = parseEvent(s.envelope); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(() => renderEvent(parsed.data, { projectSlug: 'demo', githubRepo: 'me/repo', autofixEnabled: true })).not.toThrow(); + } + } + }); + + it('sample ids are unique', () => { + const ids = SAMPLE_ENTRIES.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('each entry envelope.type matches its declared type', () => { + for (const s of SAMPLE_ENTRIES) { + expect((s.envelope as { type: string }).type).toBe(s.type); + } + }); + + it('sampleById resolves known ids and returns undefined otherwise', () => { + expect(sampleById('signup')?.type).toBe('signup'); + expect(sampleById('signup-waitlist')?.type).toBe('signup'); + // Cast: exercising the runtime guard for an id outside the typed union. + expect(sampleById('nope' as SampleId)).toBeUndefined(); + }); + + it('includes the new variant + field samples', () => { + const waitlist = sampleById('signup-waitlist'); + expect((waitlist?.envelope as { payload: { kind?: string } }).payload.kind).toBe('waitlist'); + + const addonCancel = sampleById('subscription-addon-cancel'); + expect((addonCancel?.envelope as { payload: { kind?: string } }).payload.kind).toBe('addon_cancel'); + + const feedback = sampleById('feedback'); + expect((feedback?.envelope as { payload: { consoleErrors?: string[] } }).payload.consoleErrors?.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 3ba8cf7..5deed2b 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -50,4 +50,33 @@ describe('event envelope', () => { const r = parseEvent({ type: 'subscription', payload: { email: 'a@b.com', kind: 'expired' } }); expect(r.success).toBe(true); }); + it('parses a waitlist signup and still routes to users', () => { + const r = parseEvent({ type: 'signup', payload: { email: 'a@b.com', kind: 'waitlist' } }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.category).toBe('users'); + }); + it('rejects an unknown signup kind', () => { + const r = parseEvent({ type: 'signup', payload: { email: 'a@b.com', kind: 'bogus' } }); + expect(r.success).toBe(false); + }); + it('parses the addon_cancel subscription kind (routes to revenue)', () => { + const r = parseEvent({ type: 'subscription', payload: { email: 'a@b.com', kind: 'addon_cancel' } }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.category).toBe('revenue'); + }); + it('routes a signup to revenue when the caller overrides the category', () => { + // A "free signup" is just a signup posted to the revenue channel β€” a routing + // override, not a dedicated type/kind. + const r = parseEvent({ type: 'signup', category: 'revenue', payload: { email: 'a@b.com' } }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.category).toBe('revenue'); + }); + it('parses feedback with captured consoleErrors', () => { + const r = parseEvent({ + type: 'feedback', + payload: { category: 'bug', message: 'broke', consoleErrors: ['TypeError: x', 'Warning: y'] }, + }); + expect(r.success).toBe(true); + if (r.success) expect((r.data.payload as { consoleErrors: string[] }).consoleErrors).toHaveLength(2); + }); });