diff --git a/frontend/src/components/common/EmailVerificationGate.tsx b/frontend/src/components/common/EmailVerificationGate.tsx
new file mode 100644
index 00000000..cc17cdac
--- /dev/null
+++ b/frontend/src/components/common/EmailVerificationGate.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from 'react'
+import { useAuthStore } from '@/store/authStore'
+import { EmailVerificationWall } from './EmailVerificationWall'
+
+interface EmailVerificationGateProps {
+ children: ReactNode
+ /**
+ * Override the grace period (in days). Day `gracePeriodDays + 1` and beyond
+ * trigger the wall. Defaults to 6 — the spec says Day 1–6 unverified renders
+ * children and Day 7+ renders the wall.
+ */
+ gracePeriodDays?: number
+}
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000
+
+/** Whole days elapsed between two ISO timestamps (floored). */
+function daysSince(iso: string, now: number = Date.now()): number {
+ const created = Date.parse(iso)
+ if (Number.isNaN(created)) {
+ // Defensive: bad timestamp — treat as just-signed-up so we don't
+ // accidentally lock anyone out.
+ return 0
+ }
+ return Math.floor((now - created) / MS_PER_DAY)
+}
+
+/**
+ * Wraps protected content. While the current user is past the grace period
+ * without having verified their email, renders ``
+ * instead of children.
+ *
+ * Behavior:
+ * - No user (signed out): renders children (let route guards handle auth).
+ * - User has `email_verified_at`: renders children.
+ * - Day 1–6 unverified: renders children (banner is shown elsewhere).
+ * - Day 7+ unverified: renders the wall.
+ */
+export function EmailVerificationGate({
+ children,
+ gracePeriodDays = 6,
+}: EmailVerificationGateProps) {
+ const user = useAuthStore((s) => s.user)
+
+ if (!user) return <>{children}>
+ if (user.email_verified_at) return <>{children}>
+
+ const elapsed = daysSince(user.created_at)
+ if (elapsed > gracePeriodDays) {
+ return
+ }
+
+ return <>{children}>
+}
+
+export default EmailVerificationGate
diff --git a/frontend/src/components/common/EmailVerificationWall.tsx b/frontend/src/components/common/EmailVerificationWall.tsx
new file mode 100644
index 00000000..abb95e62
--- /dev/null
+++ b/frontend/src/components/common/EmailVerificationWall.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import { Loader2, MailCheck } from 'lucide-react'
+import { authApi } from '@/api/auth'
+import { useAuthStore } from '@/store/authStore'
+import { toast } from '@/lib/toast'
+import { cn } from '@/lib/utils'
+
+interface EmailVerificationWallProps {
+ className?: string
+}
+
+/**
+ * Hard wall shown after the email-verification grace period expires.
+ *
+ * Minimal v1 — Task 37 will refine copy, layout, and add the
+ * `/verify-email?token=...` route handling. Until then this gives
+ * Day 7+ unverified users a way to re-send the verification email
+ * or sign out.
+ */
+export function EmailVerificationWall({ className }: EmailVerificationWallProps) {
+ const user = useAuthStore((s) => s.user)
+ const logout = useAuthStore((s) => s.logout)
+ const [isSending, setIsSending] = useState(false)
+
+ const handleResend = async () => {
+ setIsSending(true)
+ try {
+ await authApi.sendVerificationEmail()
+ toast.success('Verification email sent')
+ } catch {
+ toast.error('Failed to send verification email')
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ const handleLogout = async () => {
+ try {
+ await logout()
+ } catch {
+ // logout swallows API errors internally
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ Verify your email to continue
+
+
+ {user?.email
+ ? `We sent a verification link to ${user.email}. Click it to unlock your account.`
+ : 'Check your inbox for the verification link we sent when you signed up.'}
+
+
+
+
+
+
+
+ )
+}
+
+export default EmailVerificationWall
diff --git a/frontend/src/components/common/FeatureGate.tsx b/frontend/src/components/common/FeatureGate.tsx
new file mode 100644
index 00000000..e27237d4
--- /dev/null
+++ b/frontend/src/components/common/FeatureGate.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from 'react'
+import { useFeature } from '@/hooks/useFeature'
+import { UpgradePrompt } from './UpgradePrompt'
+
+interface FeatureGateProps {
+ /** Feature flag key (e.g. `psa_integration`). Must match a backend `feature_flags.flag_key`. */
+ feature: string
+ /**
+ * Rendered when the feature is enabled for the current account.
+ */
+ children: ReactNode
+ /**
+ * Rendered when the feature is disabled. Defaults to ``.
+ * Pass `null` to render nothing.
+ */
+ fallback?: ReactNode
+}
+
+/**
+ * Conditionally renders `children` based on whether `feature` is enabled
+ * for the current account.
+ *
+ * This is a UX affordance — the security boundary is the backend
+ * `require_feature` dependency. Never trust this gate for authorization.
+ */
+export function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
+ const enabled = useFeature(feature)
+
+ if (enabled) {
+ return <>{children}>
+ }
+
+ // Use explicit fallback when provided, otherwise render the standard prompt.
+ // `null` is a valid fallback (renders nothing).
+ if (fallback !== undefined) {
+ return <>{fallback}>
+ }
+
+ return
+}
+
+export default FeatureGate
diff --git a/frontend/src/components/common/UpgradePrompt.tsx b/frontend/src/components/common/UpgradePrompt.tsx
new file mode 100644
index 00000000..7780d717
--- /dev/null
+++ b/frontend/src/components/common/UpgradePrompt.tsx
@@ -0,0 +1,111 @@
+import { Lock, Sparkles } from 'lucide-react'
+import { Link } from 'react-router-dom'
+import { cn } from '@/lib/utils'
+
+interface UpgradePromptProps {
+ feature: string
+ className?: string
+}
+
+interface FeatureMeta {
+ /** Display name shown in the prompt heading. */
+ displayName: string
+ /** Plan that unlocks this feature. */
+ requiredPlan: string
+ /** Optional one-line value pitch. */
+ description?: string
+}
+
+/**
+ * Mapping from feature flag key to display metadata.
+ *
+ * v1: small inline table maintained here. If this grows, lift to
+ * `frontend/src/lib/featureCatalog.ts` and source from a backend endpoint.
+ *
+ * Keys must match `feature_flags.flag_key` on the backend.
+ */
+const FEATURE_CATALOG: Record = {
+ psa_integration: {
+ displayName: 'PSA Integration',
+ requiredPlan: 'Pro',
+ description: 'Sync tickets and assets with your PSA in real time.',
+ },
+ kb_accelerator: {
+ displayName: 'Knowledge Base Accelerator',
+ requiredPlan: 'Pro',
+ description: 'Auto-generate troubleshooting flows from your existing KB.',
+ },
+ ai_builder: {
+ displayName: 'AI Builder',
+ requiredPlan: 'Pro',
+ description: 'Generate decision trees from natural-language prompts.',
+ },
+ branching_logic: {
+ displayName: 'Branching Logic',
+ requiredPlan: 'Pro',
+ },
+ custom_branding: {
+ displayName: 'Custom Branding',
+ requiredPlan: 'Pro',
+ },
+ api_access: {
+ displayName: 'API Access',
+ requiredPlan: 'Pro',
+ },
+ sso: {
+ displayName: 'Single Sign-On',
+ requiredPlan: 'Enterprise',
+ },
+}
+
+/** Humanize an unknown feature key for the fallback display name. */
+function humanizeFeatureKey(key: string): string {
+ return key
+ .split('_')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+}
+
+/**
+ * Standardized "this feature is on Pro" affordance.
+ *
+ * Renders a locked panel with a CTA that routes to the plan-selection page.
+ * The actual gating is enforced server-side via `require_feature` — this is UX.
+ */
+export function UpgradePrompt({ feature, className }: UpgradePromptProps) {
+ const meta = FEATURE_CATALOG[feature]
+ const displayName = meta?.displayName ?? humanizeFeatureKey(feature)
+ const requiredPlan = meta?.requiredPlan ?? 'Pro'
+ const description = meta?.description
+
+ return (
+