diff --git a/src/components/ProBadge.test.tsx b/src/components/ProBadge.test.tsx new file mode 100644 index 00000000..a5139ecd --- /dev/null +++ b/src/components/ProBadge.test.tsx @@ -0,0 +1,31 @@ +// Tests for src/components/ProBadge.tsx +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { ProBadge } from './ProBadge' + +afterEach(cleanup) + +describe('ProBadge', () => { + it('renders "Pro" text', () => { + const { getByText } = render() + expect(getByText('Pro')).toBeTruthy() + }) + + it('renders with brand color #1A73E8', () => { + const { getByText } = render() + const el = getByText('Pro') + expect(el.style.backgroundColor).toBe('rgb(26, 115, 232)') + }) + + it('has pro-badge slot attribute', () => { + const { container } = render() + const el = container.querySelector('[data-slot="pro-badge"]') + expect(el).toBeTruthy() + }) + + it('merges custom className', () => { + const { container } = render() + const el = container.querySelector('[data-slot="pro-badge"]') + expect(el?.className).toContain('extra-class') + }) +}) diff --git a/src/components/ProBadge.tsx b/src/components/ProBadge.tsx new file mode 100644 index 00000000..10b3f89e --- /dev/null +++ b/src/components/ProBadge.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils' + +interface ProBadgeProps { + className?: string +} + +export function ProBadge({ className }: ProBadgeProps) { + return ( + + Pro + + ) +} diff --git a/src/components/UpgradeHint.test.tsx b/src/components/UpgradeHint.test.tsx new file mode 100644 index 00000000..cecdb736 --- /dev/null +++ b/src/components/UpgradeHint.test.tsx @@ -0,0 +1,109 @@ +// Tests for src/components/UpgradeHint.tsx +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { UpgradeHint } from './UpgradeHint' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/hooks/useEntitlement', () => ({ + useEntitlement: vi.fn(), +})) + +vi.mock('./ui/button', () => ({ + Button: ({ + children, + asChild, + ...props + }: { + children: React.ReactNode + asChild?: boolean + [key: string]: unknown + }) => + asChild ? ( + children + ) : ( + + ), +})) + +vi.mock('./ui/card', () => ({ + Card: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + CardFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +import { useEntitlement } from '@/hooks/useEntitlement' + +function setupEntitlement(bound: boolean) { + vi.mocked(useEntitlement).mockReturnValue({ + bound, + plan: bound ? 'community' : null, + features: [], + hasFeature: () => false, + isLoading: false, + isError: false, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +afterEach(cleanup) + +describe('UpgradeHint — unbound state', () => { + beforeEach(() => setupEntitlement(false)) + + it('renders headline "Unlock with ZPan Pro"', () => { + const { getByText } = render() + expect(getByText('Unlock with ZPan Pro')).toBeTruthy() + }) + + it('renders CTA "Connect to Cloud" when not bound', () => { + const { getByText } = render() + expect(getByText('Connect to Cloud')).toBeTruthy() + }) + + it('links CTA to /settings/billing', () => { + const { getByRole } = render() + const link = getByRole('link') + expect(link.getAttribute('href')).toBe('/settings/billing') + }) + + it('mentions the feature in the description', () => { + const { getByText } = render() + expect(getByText(/white-label/i)).toBeTruthy() + }) +}) + +describe('UpgradeHint — bound state', () => { + beforeEach(() => setupEntitlement(true)) + + it('renders CTA "Manage on Cloud" when bound', () => { + const { getByText } = render() + expect(getByText('Manage on Cloud')).toBeTruthy() + }) + + it('does not render "Connect to Cloud" when bound', () => { + const { queryByText } = render() + expect(queryByText('Connect to Cloud')).toBeNull() + }) +}) + +describe('UpgradeHint — data-slot', () => { + beforeEach(() => setupEntitlement(false)) + + it('renders with upgrade-hint slot attribute', () => { + const { container } = render() + const el = container.querySelector('[data-slot="upgrade-hint"]') + expect(el).toBeTruthy() + }) +}) diff --git a/src/components/UpgradeHint.tsx b/src/components/UpgradeHint.tsx new file mode 100644 index 00000000..5fb8222d --- /dev/null +++ b/src/components/UpgradeHint.tsx @@ -0,0 +1,39 @@ +import type { ProFeature } from '@shared/types' +import { useEntitlement } from '@/hooks/useEntitlement' +import { Button } from './ui/button' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card' + +const FEATURE_LABELS: Record = { + white_label: 'white-label branding', + open_registration: 'open registration', + teams_unlimited: 'unlimited teams', + team_quotas: 'per-team storage quotas', +} + +interface UpgradeHintProps { + feature: ProFeature +} + +export function UpgradeHint({ feature }: UpgradeHintProps) { + const { bound } = useEntitlement() + const featureLabel = FEATURE_LABELS[feature] ?? feature + + return ( + + + Unlock with ZPan Pro + + +

+ {featureLabel.charAt(0).toUpperCase() + featureLabel.slice(1)} is a Pro feature. Upgrade your plan to access + it. +

+
+ + + +
+ ) +} diff --git a/src/hooks/useEntitlement.ts b/src/hooks/useEntitlement.ts new file mode 100644 index 00000000..539e6d98 --- /dev/null +++ b/src/hooks/useEntitlement.ts @@ -0,0 +1,29 @@ +import type { ProFeature } from '@shared/types' +import { useQuery } from '@tanstack/react-query' +import { getLicensingStatus } from '@/lib/api' + +export const entitlementQueryKey = ['licensing', 'status'] as const + +export function useEntitlement() { + const { data, isLoading, isError } = useQuery({ + queryKey: entitlementQueryKey, + queryFn: getLicensingStatus, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }) + + function hasFeature(name: ProFeature): boolean { + if (!data?.bound || !data.features) return false + if (data.expires_at != null && Date.now() > data.expires_at * 1000) return false + return data.features.includes(name) + } + + return { + bound: data?.bound ?? false, + plan: data?.plan ?? null, + features: data?.features ?? [], + hasFeature, + isLoading, + isError, + } +}