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,
+ }
+}