From ec9b639f450f062124226cae39fb14d5e23d4a98 Mon Sep 17 00:00:00 2001 From: Chris Schofield Date: Thu, 13 Aug 2026 14:50:05 +0100 Subject: [PATCH 1/3] fix: make the site actually deployable, and finish the OpenRouter migration podflow.cc has served a 404 since the day it was first deployed on 27 June. Both production deployments before today reported READY with zero output files, so nothing ever flagged it. The cause was a chain of four separate problems. Vercel was building the wrong thing - The project had no Root Directory set, so it built the repo root, whose build script compiles the CLI. No website was produced, an empty deployment shipped, and Vercel called it READY. Root Directory is now set to `web`. The site depended on a directory outside the repo - web/package.json declared "@totalaudiopromo/ui": "file:../../newsjack/packages/ui", a path to a sibling checkout. It resolved on one machine and nowhere else. web/ was also the only directory missing from pnpm-workspace.yaml, which is why it reached outside instead of using the workspace. Both fixed: web is a workspace member and the dependency is workspace:*. packages/ui had never been built - It declared "build": "tsup" with no config and no entry, so dist/ did not exist and nothing importing the package could resolve it. Added a tsup config with the two entries the exports map already promised, splitting them so only the client barrel carries the 'use client' banner and ./app stays RSC-safe. The package had diverged into two copies - newsjack and podflow both contained @totalaudiopromo/ui@0.1.0 with different contents. newsjack's is the superset (GlassCard, GradientButton, PricingTable, CrossSell, LeadCaptureForm) and is what web/ was written against. Synced podflow up to it and migrated apps/web to the newer component APIs: PageHeader's subtitle/action/badge became description/actions plus a SectionLabel, StatCard takes a LucideIcon rather than an element and folds its delta into the limit line, ActionTile's description became subtitle. Also - pnpm-workspace.yaml carried two literal "set this to true or false" placeholders for core-js and sharp, so pnpm treated the decision as unmade and failed every install. Resolved: sharp needs its native build, core-js does not. - react and react-dom resolved to 19.2.4 and 19.2.8, which React refuses to run. Pinned together via workspace overrides. - Finished the 27 July OpenRouter migration, which landed in the other seven repos but never got committed here: default provider and model move to openrouter and deepseek/deepseek-v4-flash. Both slugs verified live against the OpenRouter /models API today. Verified: packages/ui, web and apps/web all build; root typecheck clean; 39 tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AeYoJmE6BUfw9AZJxoK9Fx --- apps/web/app/dashboard/angles/page.tsx | 29 +- apps/web/app/dashboard/billing/page.tsx | 8 +- apps/web/app/dashboard/directory/page.tsx | 29 +- apps/web/app/dashboard/page.tsx | 64 +- apps/web/app/dashboard/pitch/page.tsx | 8 +- apps/web/app/dashboard/settings/page.tsx | 12 +- packages/ui/src/ActionTile.tsx | 73 +- packages/ui/src/AnimatedSection.tsx | 31 +- packages/ui/src/Button.tsx | 80 +- packages/ui/src/Card.tsx | 32 +- packages/ui/src/CrossSell.tsx | 78 + packages/ui/src/EmptyState.tsx | 33 +- packages/ui/src/FAQAccordion.tsx | 61 +- packages/ui/src/GlassCard.tsx | 14 + packages/ui/src/GradientButton.tsx | 23 + packages/ui/src/LeadCaptureForm.tsx | 87 + packages/ui/src/MarketingNav.tsx | 8 +- packages/ui/src/PageHeader.tsx | 32 +- packages/ui/src/PricingTable.tsx | 65 + packages/ui/src/SectionLabel.tsx | 15 +- packages/ui/src/StatCard.tsx | 62 +- packages/ui/src/TapBanner.tsx | 25 +- packages/ui/src/app.ts | 8 + packages/ui/src/index.ts | 25 +- packages/ui/src/useInView.ts | 72 +- packages/ui/tailwind-preset.cjs | 28 +- packages/ui/tokens.css | 57 +- packages/ui/tsup.config.ts | 25 + pnpm-lock.yaml | 485 +++- pnpm-workspace.yaml | 8 +- src/ai/extractor.ts | 2 +- src/config/index.ts | 4 +- web/package-lock.json | 2603 --------------------- web/package.json | 5 +- 34 files changed, 1174 insertions(+), 3017 deletions(-) create mode 100644 packages/ui/src/CrossSell.tsx create mode 100644 packages/ui/src/GlassCard.tsx create mode 100644 packages/ui/src/GradientButton.tsx create mode 100644 packages/ui/src/LeadCaptureForm.tsx create mode 100644 packages/ui/src/PricingTable.tsx create mode 100644 packages/ui/tsup.config.ts delete mode 100644 web/package-lock.json diff --git a/apps/web/app/dashboard/angles/page.tsx b/apps/web/app/dashboard/angles/page.tsx index 3c06f7e..bec6bc4 100644 --- a/apps/web/app/dashboard/angles/page.tsx +++ b/apps/web/app/dashboard/angles/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { PageHeader, Card, StatCard, Button } from '@totalaudiopromo/ui/app' +import { PageHeader, Card, StatCard, Button, SectionLabel } from '@totalaudiopromo/ui/app' import { Sparkles, Search, Copy, CheckCircle2, Lightbulb, Tag } from 'lucide-react' // Real extracted ideas from Podflow digest pipeline @@ -62,30 +62,33 @@ export default function AnglesPage() { return (
- AI EXTRACTOR +
} + limit="Processed in latest digest run" + icon={Lightbulb} + iconClassName="text-amber-400" /> } + limit="Flagged by AI extraction engine" + icon={Sparkles} + iconClassName="text-purple-400" /> } + limit="Anthropic / OpenAI API cost" + icon={Tag} + iconClassName="text-emerald-400" />
diff --git a/apps/web/app/dashboard/billing/page.tsx b/apps/web/app/dashboard/billing/page.tsx index 881a902..45e795c 100644 --- a/apps/web/app/dashboard/billing/page.tsx +++ b/apps/web/app/dashboard/billing/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { PageHeader, Card, Button, StatCard } from '@totalaudiopromo/ui/app' +import { PageHeader, Card, Button, StatCard, SectionLabel } from '@totalaudiopromo/ui/app' import { CreditCard, Check, ShieldCheck, Cpu, Terminal, ExternalLink } from 'lucide-react' export default function BillingPage() { @@ -9,10 +9,10 @@ export default function BillingPage() { return (
- SUBSCRIPTION & LOCAL CLI TIERS + {/* Active Plan Summary */} diff --git a/apps/web/app/dashboard/directory/page.tsx b/apps/web/app/dashboard/directory/page.tsx index 8f4ea7f..28e7fd6 100644 --- a/apps/web/app/dashboard/directory/page.tsx +++ b/apps/web/app/dashboard/directory/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { PageHeader, Card, StatCard, Button, EmptyState } from '@totalaudiopromo/ui/app' +import { PageHeader, Card, StatCard, Button, EmptyState, SectionLabel } from '@totalaudiopromo/ui/app' import { Radio, Search, Filter, Headphones, ExternalLink, Sparkles, CheckCircle2 } from 'lucide-react' // Real podcast directory extracted from Podflow engine runs @@ -95,30 +95,33 @@ export default function DirectoryPage() { return (
- REAL PODCAST INDEX +
} + limit="From local Apple Podcasts DB & RSS" + icon={Radio} + iconClassName="text-purple-400" /> } + limit="Configured in ~/.podflow/podcasts.json" + icon={Sparkles} + iconClassName="text-amber-400" /> } + limit="Processed in latest run" + icon={Headphones} + iconClassName="text-emerald-400" />
diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 3f2bb7b..d15eb7f 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -6,8 +6,7 @@ import { Card, StatCard, ActionTile, - EmptyState -} from '@totalaudiopromo/ui/app' + EmptyState, SectionLabel } from '@totalaudiopromo/ui/app' import { Radio, Sparkles, @@ -99,11 +98,11 @@ export default function DashboardPage() { return (
{/* Header */} - PODCAST INTELLIGENCE & GUEST PITCHING + Scan New Podcasts @@ -114,36 +113,32 @@ export default function DashboardPage() { {/* Stat Metrics Grid with Distinct Multi-Color Accents */}
} + limit="Active RSS feeds & Spotify charts · +12 this week" + icon={Radio} + iconClassName="text-purple-400" /> } + limit="Relevance match across active roster · +4.1%" + icon={Sparkles} + iconClassName="text-emerald-400" /> } + limit="Outreach to verified hosts · +8 today" + icon={Send} + iconClassName="text-cyan-400" /> } + limit="Confirmed interview bookings · 39.5% rate" + icon={Users} + iconClassName="text-amber-400" />
@@ -151,21 +146,18 @@ export default function DashboardPage() {
} - badge="AUTOMATED" + subtitle="Run real-time NLP analysis on latest podcast drops in your genre." + icon={Radio} /> } - badge="AI GENERATED" + subtitle="Generate personalized guest pitch emails referencing host episode angles." + icon={Sparkles} /> } - badge="EXPORT" + subtitle="Download PDF/Markdown digest of top podcast opportunities for your team." + icon={FileText} />
@@ -235,7 +227,7 @@ export default function DashboardPage() { {/* Opportunity List */} {filteredOpportunities.length === 0 ? ( } + icon={Radio} title="No podcast opportunities found" description="Try adjusting your search query or filter to see more podcast host matches." /> diff --git a/apps/web/app/dashboard/pitch/page.tsx b/apps/web/app/dashboard/pitch/page.tsx index 9d1a523..4c9078d 100644 --- a/apps/web/app/dashboard/pitch/page.tsx +++ b/apps/web/app/dashboard/pitch/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { PageHeader, Card, Button } from '@totalaudiopromo/ui/app' +import { PageHeader, Card, Button, SectionLabel } from '@totalaudiopromo/ui/app' import { Send, Sparkles, Copy, CheckCircle2, User, Radio, RefreshCw } from 'lucide-react' export default function PitchDrafterPage() { @@ -30,10 +30,10 @@ Total Audio PR` return (
- AI PITCH GENERATOR +
diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx index e27555f..9357dc9 100644 --- a/apps/web/app/dashboard/settings/page.tsx +++ b/apps/web/app/dashboard/settings/page.tsx @@ -1,12 +1,12 @@ 'use client' import { useState } from 'react' -import { PageHeader, Card, Button } from '@totalaudiopromo/ui/app' +import { PageHeader, Card, Button, SectionLabel } from '@totalaudiopromo/ui/app' import { Settings, Key, Cpu, Calendar, CheckCircle2, Save, Globe } from 'lucide-react' export default function SettingsPage() { const [provider, setProvider] = useState('openrouter') - const [model, setModel] = useState('anthropic/claude-3.5-haiku') + const [model, setModel] = useState('deepseek/deepseek-v4-flash') const [apiKey, setApiKey] = useState('sk-or-v1-****************') const [scheduleTime, setScheduleTime] = useState('08:00') const [saved, setSaved] = useState(false) @@ -14,7 +14,7 @@ export default function SettingsPage() { const handleProviderChange = (newProvider: string) => { setProvider(newProvider) if (newProvider === 'openrouter') { - setModel('anthropic/claude-3.5-haiku') + setModel('deepseek/deepseek-v4-flash') } else if (newProvider === 'anthropic') { setModel('claude-haiku-4-5-20251001') } else if (newProvider === 'openai') { @@ -33,10 +33,10 @@ export default function SettingsPage() { return (
- CONFIG & PROVIDERS + diff --git a/packages/ui/src/ActionTile.tsx b/packages/ui/src/ActionTile.tsx index 70a6163..633953c 100644 --- a/packages/ui/src/ActionTile.tsx +++ b/packages/ui/src/ActionTile.tsx @@ -1,54 +1,47 @@ import React from 'react' +import type { LucideIcon } from 'lucide-react' -export interface ActionTileProps { - title: string - description: string - icon: React.ReactNode - onClick?: () => void +interface ActionTileProps { + icon: LucideIcon + title: React.ReactNode + subtitle?: React.ReactNode + /** When set, the tile renders as a link. */ href?: string - badge?: string + onClick?: () => void className?: string } -export function ActionTile({ - title, - description, - icon, - onClick, - href, - badge, - className = '', -}: ActionTileProps) { - const content = ( -
-
-
- {icon} -
- {badge && ( - - {badge} - - )} +const TILE = + 'flex items-center gap-4 p-4 rounded-xl border border-white/10 bg-white/[0.03] hover:border-brand-500/50 hover:bg-white/10 transition-colors text-left w-full' + +/** Icon + title + subtitle tile for quick actions. Renders , - ) + if (onClick) { + return ( + + ) + } + return
{inner}
} diff --git a/packages/ui/src/AnimatedSection.tsx b/packages/ui/src/AnimatedSection.tsx index b33b028..a55a185 100644 --- a/packages/ui/src/AnimatedSection.tsx +++ b/packages/ui/src/AnimatedSection.tsx @@ -6,24 +6,47 @@ import { useInView } from './useInView' interface AnimatedSectionProps { children: React.ReactNode className?: string + /** Use 'stagger' for grid children that should animate in sequence */ animation?: 'slide' | 'stagger' + /** Delay in ms before animation starts */ + delay?: number } export function AnimatedSection({ children, className = '', animation = 'slide', -}: AnimatedSectionProps) { - const { ref, isInView } = useInView() + delay = 0, +}: AnimatedSectionProps): React.JSX.Element { + const [ref, inView] = useInView({ threshold: 0.1 }) - const animClass = animation === 'stagger' ? 'stagger-grid' : 'animate-slide-in' + const baseClass = animation === 'stagger' ? 'stagger-grid' : 'animate-slide-in' + const viewClass = inView ? 'in-view' : '' return (
{children}
) } + +/** Wrapper for individual elements within a stagger grid. */ +export function AnimatedItem({ + children, + className = '', + delay = 0, +}: { + children: React.ReactNode + className?: string + delay?: number +}): React.JSX.Element { + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx index 210e682..932732b 100644 --- a/packages/ui/src/Button.tsx +++ b/packages/ui/src/Button.tsx @@ -1,40 +1,62 @@ import React from 'react' -export interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'outline' | 'ghost' - size?: 'sm' | 'md' | 'lg' - children: React.ReactNode - className?: string +type Variant = 'primary' | 'secondary' | 'ghost' +type Size = 'sm' | 'md' | 'lg' + +// Neutral variants get full box sizing. Primary uses the themeable btn-gradient +// utility, which owns its own padding/radius, so it only takes a text size. +const neutralSize: Record = { + sm: 'px-4 py-2 text-sm rounded-lg', + md: 'px-5 py-2.5 text-sm rounded-lg', + lg: 'px-6 py-3 text-base rounded-xl', +} +const primaryTextSize: Record = { + sm: 'text-sm', + md: 'text-sm', + lg: 'text-base', } -export function Button({ - variant = 'primary', - size = 'md', - children, - className = '', - ...props -}: ButtonProps) { - const baseStyle = - 'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed' +const variantBase: Record = { + primary: 'btn-gradient inline-flex items-center justify-center gap-2 font-medium', + secondary: + 'inline-flex items-center justify-center gap-2 font-medium bg-white/10 hover:bg-white/20 text-white transition-colors', + ghost: + 'inline-flex items-center justify-center gap-2 font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors', +} + +type CommonProps = { + variant?: Variant + size?: Size + className?: string + children: React.ReactNode +} - const sizeStyle = - size === 'sm' - ? 'px-3 py-1.5 text-xs' - : size === 'lg' - ? 'px-6 py-3 text-base' - : 'px-4 py-2 text-sm' +type ButtonAsButton = CommonProps & + Omit, keyof CommonProps> & { href?: undefined } +type ButtonAsAnchor = CommonProps & + Omit, keyof CommonProps> & { href: string } - const variantStyle = - variant === 'primary' - ? 'bg-purple-600 hover:bg-purple-500 text-white shadow-glow' - : variant === 'secondary' - ? 'bg-white/10 hover:bg-white/15 text-white border border-white/10' - : variant === 'outline' - ? 'bg-transparent border border-purple-500/40 text-purple-300 hover:bg-purple-500/10' - : 'text-slate-300 hover:text-white hover:bg-white/5' +/** + * One button, three variants. Renders an when `href` is given, otherwise a + * ) diff --git a/packages/ui/src/Card.tsx b/packages/ui/src/Card.tsx index b553889..873ae91 100644 --- a/packages/ui/src/Card.tsx +++ b/packages/ui/src/Card.tsx @@ -1,20 +1,30 @@ import React from 'react' -export interface CardProps extends React.HTMLAttributes { - children: React.ReactNode - className?: string - glass?: boolean +type Padding = 'none' | 'sm' | 'md' | 'lg' + +const paddingClass: Record = { + none: '', + sm: 'p-4', + md: 'p-6', + lg: 'p-8', +} + +interface CardProps extends React.HTMLAttributes { + padding?: Padding + /** Adds a subtle brand-tinted left border for "featured" cards. */ + accent?: boolean } -export function Card({ children, className = '', glass = true, ...props }: CardProps) { +/** + * The house surface for app content — frosted card, consistent radius and + * border. Wraps the themeable `glass-card` utility so newsjack, spotcheck and + * podflow share one card look and pick up their accent from `data-theme`. + */ +export function Card({ padding = 'md', accent = false, className = '', children, ...rest }: CardProps) { return (
{children}
diff --git a/packages/ui/src/CrossSell.tsx b/packages/ui/src/CrossSell.tsx new file mode 100644 index 0000000..1aeaf01 --- /dev/null +++ b/packages/ui/src/CrossSell.tsx @@ -0,0 +1,78 @@ +'use client' + +import React, { useState } from 'react' +import { ArrowRight, X } from 'lucide-react' + +interface CrossSellProps { + badge: string + headline: string + subtext: string + cta: string + href: string + /** Leading icon for the badge row */ + badgeIcon?: React.ReactNode + /** Leading icon inside the CTA pill */ + ctaIcon?: React.ReactNode + onDismiss?: () => void + className?: string +} + +/** + * Dismissible cross-sell banner pointing at another TAP product + * (e.g. newsjack → Total Audio Promo Intel). Indigo/violet treatment is + * deliberately distinct from the host product's brand accent. + */ +export function CrossSell({ + badge, + headline, + subtext, + cta, + href, + badgeIcon, + ctaIcon, + onDismiss, + className = '', +}: CrossSellProps) { + const [dismissed, setDismissed] = useState(false) + + if (dismissed) return null + + return ( +
+
+ + + +
+
+ {badgeIcon} + {badge} +
+ +

{headline}

+ +

{subtext}

+ + + {ctaIcon} + {cta} + + +
+
+ ) +} diff --git a/packages/ui/src/EmptyState.tsx b/packages/ui/src/EmptyState.tsx index eee09b2..b5fc80f 100644 --- a/packages/ui/src/EmptyState.tsx +++ b/packages/ui/src/EmptyState.tsx @@ -1,30 +1,23 @@ import React from 'react' +import type { LucideIcon } from 'lucide-react' -export interface EmptyStateProps { - icon?: React.ReactNode - title: string - description: string +interface EmptyStateProps { + icon?: LucideIcon + title: React.ReactNode + description?: React.ReactNode + /** Optional call to action (a Button, link, etc.). */ action?: React.ReactNode className?: string } -export function EmptyState({ - icon, - title, - description, - action, - className = '', -}: EmptyStateProps) { +/** Centred empty/placeholder state inside a card. */ +export function EmptyState({ icon: Icon, title, description, action, className = '' }: EmptyStateProps) { return ( -
- {icon && ( -
- {icon} -
- )} -

{title}

-

{description}

- {action &&
{action}
} +
+ {Icon && } +

{title}

+ {description &&

{description}

} + {action}
) } diff --git a/packages/ui/src/FAQAccordion.tsx b/packages/ui/src/FAQAccordion.tsx index 1f5ee51..3e10a17 100644 --- a/packages/ui/src/FAQAccordion.tsx +++ b/packages/ui/src/FAQAccordion.tsx @@ -1,11 +1,9 @@ -'use client' - -import React, { useState } from 'react' +import React from 'react' import { ChevronDown } from 'lucide-react' export interface FAQItem { question: string - answer: string + answer: React.ReactNode } interface FAQAccordionProps { @@ -13,45 +11,28 @@ interface FAQAccordionProps { className?: string } +/** + * Expandable FAQ list built on native
/ — no JS state, + * keyboard accessible out of the box. + */ export function FAQAccordion({ items, className = '' }: FAQAccordionProps) { - const [openIndex, setOpenIndex] = useState(null) - - const toggle = (idx: number) => { - setOpenIndex(openIndex === idx ? null : idx) - } - return (
- {items.map((item, idx) => { - const isOpen = openIndex === idx - return ( -
- - {isOpen && ( -
- {item.answer} -
- )} -
- ) - })} + {items.map((item) => ( +
+ + {item.question} + +
{item.answer}
+
+ ))}
) } diff --git a/packages/ui/src/GlassCard.tsx b/packages/ui/src/GlassCard.tsx new file mode 100644 index 0000000..3aa208d --- /dev/null +++ b/packages/ui/src/GlassCard.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +interface GlassCardProps extends React.HTMLAttributes { + children: React.ReactNode +} + +/** Frosted glass card with hover lift — the TAP house card style. */ +export function GlassCard({ children, className = '', ...rest }: GlassCardProps) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui/src/GradientButton.tsx b/packages/ui/src/GradientButton.tsx new file mode 100644 index 0000000..d8fd548 --- /dev/null +++ b/packages/ui/src/GradientButton.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +type GradientButtonProps = + | ({ href: string } & React.AnchorHTMLAttributes) + | ({ href?: undefined } & React.ButtonHTMLAttributes) + +/** Primary CTA: animated brand gradient with glow. Renders when href is given. */ +export function GradientButton(props: GradientButtonProps) { + if (props.href !== undefined) { + const { className = '', children, ...rest } = props + return ( + + {children} + + ) + } + const { className = '', children, ...rest } = props + return ( + + ) +} diff --git a/packages/ui/src/LeadCaptureForm.tsx b/packages/ui/src/LeadCaptureForm.tsx new file mode 100644 index 0000000..eb535b9 --- /dev/null +++ b/packages/ui/src/LeadCaptureForm.tsx @@ -0,0 +1,87 @@ +'use client' + +import React, { useState } from 'react' +import { Loader2, Mail } from 'lucide-react' + +interface LeadCaptureFormProps { + /** POST target; receives JSON {email, source} */ + endpoint: string + /** Identifier for which magnet/page drove the signup */ + source: string + buttonLabel?: string + placeholder?: string + successMessage?: string + className?: string +} + +/** Email capture form posting to a lead-capture endpoint. */ +export function LeadCaptureForm({ + endpoint, + source, + buttonLabel = 'Send it to me', + placeholder = 'you@example.com', + successMessage = 'Check your inbox — it’s on the way.', + className = '', +}: LeadCaptureFormProps) { + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [error, setError] = useState(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!email.trim()) return + + setStatus('loading') + setError(null) + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim(), source }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new Error(data?.error || 'Something went wrong — try again.') + } + + setStatus('success') + } catch (err) { + setStatus('error') + setError(err instanceof Error ? err.message : 'Something went wrong — try again.') + } + } + + if (status === 'success') { + return

{successMessage}

+ } + + return ( +
+
+ + setEmail(e.target.value)} + placeholder={placeholder} + aria-label="Email address" + className="w-full pl-12 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ + {status === 'error' && error && ( +

{error}

+ )} +
+ ) +} diff --git a/packages/ui/src/MarketingNav.tsx b/packages/ui/src/MarketingNav.tsx index c18f6a4..3012722 100644 --- a/packages/ui/src/MarketingNav.tsx +++ b/packages/ui/src/MarketingNav.tsx @@ -19,7 +19,7 @@ interface MarketingNavProps { /** * Fixed marketing-site navigation with a mobile hamburger panel. - * Uses max-w-6xl mx-auto container width. + * Desktop links use the .nav-link underline treatment from the preset. */ export function MarketingNav({ logo, links, cta, className = '' }: MarketingNavProps) { const [open, setOpen] = useState(false) @@ -28,13 +28,13 @@ export function MarketingNav({ logo, links, cta, className = '' }: MarketingNavP