feat: v2.6 Z3 — ProBadge, UpgradeHint, useEntitlement frontend primitives (#342)

Backend (has-feature, requireFeature, /api/licensing/status) already shipped in Z2/Z4.
This PR contributes the frontend feature-gate primitives only:

- src/components/ProBadge.tsx: "Pro" pill badge with brand color #1A73E8
- src/components/UpgradeHint.tsx: block upsell card; CTA "Connect to Cloud" (unbound)
  or "Manage on Cloud" (bound but feature missing); links to /settings/billing
- src/hooks/useEntitlement.ts: React Query hook wrapping getLicensingStatus;
  query key ['licensing','status'], 60s stale/refetch, exposes hasFeature(name)
- src/components/ProBadge.test.tsx: 4 render tests (text, brand color, slot, className)
- src/components/UpgradeHint.test.tsx: 7 render tests (headline, CTA per bound state,
  link target, feature label, slot attribute)

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-24 07:49:17 -04:00
committed by GitHub
co-authored by Bob
parent 86fab7716b
commit c05bfc7c20
5 changed files with 225 additions and 0 deletions
+31
View File
@@ -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(<ProBadge />)
expect(getByText('Pro')).toBeTruthy()
})
it('renders with brand color #1A73E8', () => {
const { getByText } = render(<ProBadge />)
const el = getByText('Pro')
expect(el.style.backgroundColor).toBe('rgb(26, 115, 232)')
})
it('has pro-badge slot attribute', () => {
const { container } = render(<ProBadge />)
const el = container.querySelector('[data-slot="pro-badge"]')
expect(el).toBeTruthy()
})
it('merges custom className', () => {
const { container } = render(<ProBadge className="extra-class" />)
const el = container.querySelector('[data-slot="pro-badge"]')
expect(el?.className).toContain('extra-class')
})
})
+17
View File
@@ -0,0 +1,17 @@
import { cn } from '@/lib/utils'
interface ProBadgeProps {
className?: string
}
export function ProBadge({ className }: ProBadgeProps) {
return (
<span
data-slot="pro-badge"
className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold text-white', className)}
style={{ backgroundColor: '#1A73E8' }}
>
Pro
</span>
)
}
+109
View File
@@ -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
) : (
<button type="button" {...props}>
{children}
</button>
),
}))
vi.mock('./ui/card', () => ({
Card: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
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(<UpgradeHint feature="white_label" />)
expect(getByText('Unlock with ZPan Pro')).toBeTruthy()
})
it('renders CTA "Connect to Cloud" when not bound', () => {
const { getByText } = render(<UpgradeHint feature="teams_unlimited" />)
expect(getByText('Connect to Cloud')).toBeTruthy()
})
it('links CTA to /settings/billing', () => {
const { getByRole } = render(<UpgradeHint feature="white_label" />)
const link = getByRole('link')
expect(link.getAttribute('href')).toBe('/settings/billing')
})
it('mentions the feature in the description', () => {
const { getByText } = render(<UpgradeHint feature="white_label" />)
expect(getByText(/white-label/i)).toBeTruthy()
})
})
describe('UpgradeHint — bound state', () => {
beforeEach(() => setupEntitlement(true))
it('renders CTA "Manage on Cloud" when bound', () => {
const { getByText } = render(<UpgradeHint feature="team_quotas" />)
expect(getByText('Manage on Cloud')).toBeTruthy()
})
it('does not render "Connect to Cloud" when bound', () => {
const { queryByText } = render(<UpgradeHint feature="team_quotas" />)
expect(queryByText('Connect to Cloud')).toBeNull()
})
})
describe('UpgradeHint — data-slot', () => {
beforeEach(() => setupEntitlement(false))
it('renders with upgrade-hint slot attribute', () => {
const { container } = render(<UpgradeHint feature="open_registration" />)
const el = container.querySelector('[data-slot="upgrade-hint"]')
expect(el).toBeTruthy()
})
})
+39
View File
@@ -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<ProFeature, string> = {
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 (
<Card data-slot="upgrade-hint">
<CardHeader>
<CardTitle>Unlock with ZPan Pro</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{featureLabel.charAt(0).toUpperCase() + featureLabel.slice(1)} is a Pro feature. Upgrade your plan to access
it.
</p>
</CardContent>
<CardFooter>
<Button asChild>
<a href="/settings/billing">{bound ? 'Manage on Cloud' : 'Connect to Cloud'}</a>
</Button>
</CardFooter>
</Card>
)
}
+29
View File
@@ -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,
}
}