Mounts a billing-state pill in the topbar that reads useTrialBanner() and renders the appropriate label / tone / CTA per spec: - pristine / warning → "Pro trial · Nd" (info → warning amber as days drop) - urgent → "Pro trial · today" (warning amber, semibold) - expired → "Trial expired — pick a plan" → /account/billing/select-plan - paid → planBilling.display_name (quiet) - complimentary → "Complimentary Pro" (accent, no CTA) - past_due → "Payment failed — update card" → /account/billing - canceled → "Reactivate" → /account/billing/select-plan - null → hidden Uses existing design-system tokens only (text-info/bg-info-dim, text-warning/bg-warning-dim, text-danger/bg-danger-dim, text-accent/ bg-accent-dim, text-muted-foreground/bg-elevated). Clickable variants render as react-router-dom <Link>s and are keyboard-focusable with an accent focus-visible ring. Mobile collapses the label to a clock icon with a title attribute carrying the full text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
156 lines
4.1 KiB
TypeScript
156 lines
4.1 KiB
TypeScript
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(
|
|
<MemoryRouter>
|
|
<TrialPill />
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
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')
|
|
})
|
|
})
|