diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 3555b7db..42e107df 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions' import { BrandLogo } from '@/components/common/BrandLogo' import { CommandPalette } from './CommandPalette' import { NotificationsPanel } from './NotificationsPanel' +import { TrialPill } from './TrialPill' import { cn } from '@/lib/utils' export function TopBar() { @@ -110,6 +111,9 @@ export function TopBar() { {/* Spacer - push actions to right */}
+ {/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */} + + {/* Action buttons */}
`s; static variants render as ``. + * + * Mobile: when the topbar is too narrow, the label collapses to a clock icon + * with a `title` tooltip carrying the full text. + */ + +interface PillContent { + /** Full label shown on >= sm. */ + label: string + /** Short label for mobile (sm:hidden); typically a single token / icon. */ + shortLabel?: string + /** Tailwind classes applied to the pill (color tokens). */ + toneClass: string + /** When set, render as a clickable Link to this route. */ + href?: string + /** Extra emphasis (used by `urgent` to differentiate from `warning`). */ + emphasized?: boolean +} + +const BASE_CLASS = + 'trial-pill inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors whitespace-nowrap' + +export function TrialPill() { + const { stage, daysRemaining } = useTrialBanner() + const planBilling = useBillingStore((s) => s.planBilling) + + const content = resolveContent(stage, daysRemaining, planBilling?.display_name ?? null) + if (!content) return null + + const className = cn( + BASE_CLASS, + content.toneClass, + content.emphasized && 'font-semibold', + content.href && + 'cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg-sidebar', + ) + + const inner = ( + <> + {content.label} + + + ) + + if (content.href) { + return ( + + {inner} + + ) + } + + return ( + + {inner} + + ) +} + +function resolveContent( + stage: ReturnType['stage'], + daysRemaining: number | null, + paidDisplayName: string | null, +): PillContent | null { + switch (stage) { + case null: + return null + case 'pristine': { + const days = daysRemaining ?? 0 + return { + label: `Pro trial · ${days}d`, + toneClass: 'text-info bg-info-dim', + } + } + case 'warning': { + const days = daysRemaining ?? 0 + return { + label: `Pro trial · ${days}d`, + toneClass: 'text-warning bg-warning-dim', + } + } + case 'urgent': + return { + label: 'Pro trial · today', + toneClass: 'text-warning bg-warning-dim', + emphasized: true, + } + case 'expired': + return { + label: 'Trial expired — pick a plan', + toneClass: 'text-danger bg-danger-dim', + href: '/account/billing/select-plan', + } + case 'paid': + return { + label: paidDisplayName ?? 'Pro', + toneClass: 'text-muted-foreground bg-elevated', + } + case 'complimentary': + return { + label: 'Complimentary Pro', + toneClass: 'text-accent bg-accent-dim', + } + case 'past_due': + return { + label: 'Payment failed — update card', + toneClass: 'text-warning bg-warning-dim', + href: '/account/billing', + } + case 'canceled': + return { + label: 'Reactivate', + toneClass: 'text-warning bg-warning-dim', + href: '/account/billing/select-plan', + } + default: { + const _exhaustive: never = stage + void _exhaustive + return null + } + } +} + +export default TrialPill diff --git a/frontend/src/components/layout/__tests__/TrialPill.test.tsx b/frontend/src/components/layout/__tests__/TrialPill.test.tsx new file mode 100644 index 00000000..cfc83328 --- /dev/null +++ b/frontend/src/components/layout/__tests__/TrialPill.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { TrialPill } from '../TrialPill' +import { useBillingStore } from '@/store/billingStore' +import type { SubscriptionState, PlanBillingState } from '@/types/billing' + +const FROZEN_NOW = new Date('2026-05-06T12:00:00Z') + +function renderPill() { + return render( + + + , + ) +} + +function setBilling(opts: { + subscription: SubscriptionState | null + planBilling?: PlanBillingState | null +}) { + useBillingStore.setState({ + subscription: opts.subscription, + planBilling: opts.planBilling ?? null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +function isoDaysFromNow(days: number): string { + const d = new Date(FROZEN_NOW.getTime() + days * 24 * 60 * 60 * 1000) + return d.toISOString() +} + +describe('TrialPill', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW) + useBillingStore.setState({ + subscription: null, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders Pro trial · Nd for pristine stage', () => { + setBilling({ + subscription: { + status: 'trialing', + plan: 'pro', + current_period_start: FROZEN_NOW.toISOString(), + current_period_end: isoDaysFromNow(12), + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: true, + is_paid: false, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Pro trial · 12d/) + // Pristine uses info tone tokens. + expect(pill.className).toContain('text-info') + expect(pill.className).toContain('bg-info-dim') + }) + + it('renders Trial expired CTA for expired stage', () => { + setBilling({ + subscription: { + status: 'trialing', + plan: 'pro', + current_period_start: isoDaysFromNow(-14), + current_period_end: isoDaysFromNow(-1), // already past + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: false, + is_paid: false, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Trial expired — pick a plan/) + // Clickable: rendered as anchor/link. + expect(pill.tagName).toBe('A') + expect(pill.getAttribute('href')).toBe('/account/billing/select-plan') + }) + + it('renders Complimentary Pro tag for complimentary subscription', () => { + setBilling({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: null, + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Complimentary Pro/) + // Friendly tag, not clickable. + expect(pill.tagName).toBe('SPAN') + expect(pill.className).toContain('text-accent') + }) + + it('is hidden when subscription is null', () => { + setBilling({ subscription: null }) + + const { container } = renderPill() + + expect(screen.queryByTestId('trial-pill')).not.toBeInTheDocument() + expect(container.firstChild).toBeNull() + }) + + it('past_due variant is clickable and links to /account/billing', () => { + setBilling({ + subscription: { + status: 'past_due', + plan: 'pro', + current_period_start: isoDaysFromNow(-30), + current_period_end: isoDaysFromNow(-2), + cancel_at_period_end: false, + seat_limit: 5, + has_pro_entitlement: false, + is_paid: true, + }, + }) + + renderPill() + + const pill = screen.getByTestId('trial-pill') + expect(pill).toHaveTextContent(/Payment failed — update card/) + expect(pill.tagName).toBe('A') + expect(pill.getAttribute('href')).toBe('/account/billing') + }) +})