mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user