diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15828ab..8546d21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} 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/package.json b/package.json index 61067ed..c762965 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,6 @@ "tsx": "^4.0.0", "typescript": "^5.7.0", "vitest": "^4.1.10" - } + }, + "packageManager": "pnpm@10.34.5" } 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