{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "paywall",
  "type": "registry:block",
  "title": "Paywall",
  "description": "A subscription screen with a drifting gradient hero, rolling plan prices and confetti on purchase.",
  "categories": [
    "screen"
  ],
  "dependencies": [
    "react-native-safe-area-context"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/confetti-burst.json",
    "https://kinetik-ui.dev/r/grain-drift.json",
    "https://kinetik-ui.dev/r/odometer.json",
    "https://kinetik-ui.dev/r/press-jelly.json",
    "https://kinetik-ui.dev/r/shine-text.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/screens/paywall.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/screens/paywall.tsx",
      "content": "import { useRef, useState } from 'react';\nimport { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\n\nimport { ConfettiBurst, type ConfettiHandle } from '../confetti-burst';\nimport { GrainDrift } from '../grain-drift';\nimport { Odometer } from '../odometer';\nimport { PressJelly } from '../press-jelly';\nimport { ShineText } from '../shine-text';\nimport { gradient } from '../../../lib/kinetik/tokens/gradient';\nimport { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, withAlpha } from '../../../lib/kinetik/tokens/tokens';\n\nexport type PaywallPlan = {\n  id: string;\n  title: string;\n  /** Price charged per period. */\n  price: number;\n  period: 'week' | 'month' | 'year';\n  /** Short label on the card, e.g. \"Save 40%\". */\n  badge?: string;\n  /** Free trial length, shown on the button and in the terms line. */\n  trialDays?: number;\n};\n\nexport type PaywallProps = {\n  /** Charge the plan. Return a promise to show a loading state; reject to stay on the paywall. */\n  onPurchase: (plan: PaywallPlan) => Promise<void> | void;\n  onRestore?: () => void;\n  onClose?: () => void;\n  onTerms?: () => void;\n  onPrivacy?: () => void;\n  plans?: readonly PaywallPlan[];\n  /** Pre-selected plan. Defaults to the first plan with a badge, else the first. */\n  initialPlan?: string;\n  title?: string;\n  subtitle?: string;\n  features?: readonly string[];\n  /** Currency symbol placed before prices. Defaults to '$'. */\n  currency?: string;\n};\n\nconst DEFAULT_PLANS: PaywallPlan[] = [\n  { id: 'yearly', title: 'Yearly', price: 39.99, period: 'year', badge: 'Save 44%', trialDays: 7 },\n  { id: 'monthly', title: 'Monthly', price: 5.99, period: 'month' },\n];\n\nconst DEFAULT_FEATURES = [\n  'Every component and screen template',\n  'Updates the day a new SDK ships',\n  'Figma kit with matching motion specs',\n  'Priority answers from the team',\n];\n\nconst PER_MONTH = { week: 52 / 12, month: 1, year: 1 / 12 } as const;\n\n/**\n * A subscription screen: a drifting gradient hero, plan cards whose monthly\n * price rolls when you switch, and confetti when the purchase goes through.\n */\nexport function Paywall({\n  onPurchase,\n  onRestore,\n  onClose,\n  onTerms,\n  onPrivacy,\n  plans = DEFAULT_PLANS,\n  initialPlan,\n  title = 'Kinetik Pro',\n  subtitle = 'Ship motion your users notice, without the weeks of tuning.',\n  features = DEFAULT_FEATURES,\n  currency = '$',\n}: PaywallProps) {\n  const { colors, space, font, radius } = useKinetikTheme();\n  const insets = useSafeAreaInsets();\n  const confetti = useRef<ConfettiHandle>(null);\n  const [selected, setSelected] = useState(\n    () => initialPlan ?? (plans.find((p) => p.badge) ?? plans[0])?.id,\n  );\n  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');\n  const plan = plans.find((p) => p.id === selected) ?? plans[0];\n  if (!plan) return null;\n\n  const perMonth = plan.price * PER_MONTH[plan.period];\n  const money = (n: number) => `${currency}${n.toFixed(2)}`;\n\n  const purchase = async () => {\n    setState('busy');\n    try {\n      await onPurchase(plan);\n      setState('done');\n      confetti.current?.burst();\n    } catch {\n      setState('idle');\n    }\n  };\n\n  const link = (label: string, onPress?: () => void) =>\n    onPress ? (\n      <Pressable\n        accessibilityRole=\"link\"\n        onPress={onPress}\n        hitSlop={8}\n        style={styles.link}\n        key={label}\n      >\n        <Text style={{ color: colors.textMuted, fontSize: font.size.xs }}>{label}</Text>\n      </Pressable>\n    ) : null;\n\n  return (\n    <ConfettiBurst ref={confetti} style={[styles.fill, { backgroundColor: colors.background }]}>\n      <ScrollView\n        style={styles.fill}\n        contentContainerStyle={{ paddingBottom: insets.bottom + space[6] }}\n        showsVerticalScrollIndicator={false}\n      >\n        <GrainDrift style={[styles.hero, { paddingTop: insets.top }]} grain={0.35}>\n          {/* Fade into the page so the title reads on any palette. */}\n          <View\n            pointerEvents=\"none\"\n            style={[\n              StyleSheet.absoluteFill,\n              gradient(\n                `linear-gradient(180deg, ${withAlpha(colors.background, 0)} 25%, ${withAlpha(colors.background, 0.85)} 80%, ${colors.background} 100%)`,\n              ),\n            ]}\n          />\n          {onClose ? (\n            <Pressable\n              accessibilityRole=\"button\"\n              accessibilityLabel=\"Close\"\n              onPress={onClose}\n              style={[\n                styles.close,\n                {\n                  top: insets.top + space[2],\n                  right: space[3],\n                  backgroundColor: withAlpha(colors.surfaceSunken, 0.4),\n                  borderRadius: radius.pill,\n                },\n              ]}\n            >\n              <Text style={{ color: colors.text, fontSize: font.size.lg, lineHeight: 20 }}>×</Text>\n            </Pressable>\n          ) : null}\n        </GrainDrift>\n\n        {/* The title sits below the gradient rather than over it: two continuously\n            redrawn Skia surfaces stacked crashed the Android emulator's GL layer. */}\n        <View style={[styles.heroText, { gap: space[2], paddingHorizontal: space[6] }]}>\n          <ShineText text={title} fontSize={font.size['4xl']} color={colors.text} />\n          <Text\n            style={{\n              color: colors.textMuted,\n              fontSize: font.size.md,\n              lineHeight: 22,\n              textAlign: 'center',\n            }}\n          >\n            {subtitle}\n          </Text>\n        </View>\n\n        <View style={{ paddingHorizontal: space[5], gap: space[6], marginTop: space[6] }}>\n          <View style={{ gap: space[3] }}>\n            {features.map((f) => (\n              <View key={f} style={[styles.feature, { gap: space[3] }]}>\n                <View\n                  style={[\n                    styles.check,\n                    { backgroundColor: colors.emberSoft, borderRadius: radius.pill },\n                  ]}\n                >\n                  <Text style={{ color: colors.ember, fontSize: font.size.xs, fontWeight: '900' }}>\n                    ✓\n                  </Text>\n                </View>\n                <Text style={{ color: colors.text, fontSize: font.size.md, flex: 1 }}>{f}</Text>\n              </View>\n            ))}\n          </View>\n\n          <View accessibilityRole=\"radiogroup\" style={{ gap: space[3] }}>\n            {plans.map((p) => {\n              const on = p.id === plan.id;\n              return (\n                <Pressable\n                  key={p.id}\n                  accessibilityRole=\"radio\"\n                  accessibilityState={{ checked: on, disabled: state !== 'idle' }}\n                  accessibilityLabel={`${p.title}, ${money(p.price)} per ${p.period}${p.badge ? `, ${p.badge}` : ''}`}\n                  disabled={state !== 'idle'}\n                  onPress={() => setSelected(p.id)}\n                  style={[\n                    styles.plan,\n                    {\n                      borderRadius: radius.lg,\n                      padding: space[4],\n                      gap: space[3],\n                      backgroundColor: on ? colors.emberSoft : colors.surface,\n                      borderColor: on ? colors.ember : colors.border,\n                    },\n                  ]}\n                >\n                  <View\n                    style={[styles.radio, { borderColor: on ? colors.ember : colors.borderStrong }]}\n                  >\n                    {on ? (\n                      <View style={[styles.radioDot, { backgroundColor: colors.ember }]} />\n                    ) : null}\n                  </View>\n                  <View style={styles.fill}>\n                    <Text\n                      style={{\n                        color: colors.text,\n                        fontSize: font.size.md,\n                        fontWeight: font.weight.bold,\n                      }}\n                    >\n                      {p.title}\n                    </Text>\n                    <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>\n                      {money(p.price)} / {p.period}\n                    </Text>\n                  </View>\n                  {p.badge ? (\n                    <View\n                      style={[\n                        styles.badge,\n                        { backgroundColor: colors.ember, borderRadius: radius.pill },\n                      ]}\n                    >\n                      <Text\n                        style={{\n                          color: colors.onEmber,\n                          fontSize: font.size.xs,\n                          fontWeight: font.weight.heavy,\n                        }}\n                      >\n                        {p.badge}\n                      </Text>\n                    </View>\n                  ) : null}\n                </Pressable>\n              );\n            })}\n          </View>\n\n          <View\n            accessible\n            accessibilityLabel={`${money(perMonth)} per month`}\n            style={[styles.priceRow, { gap: space[1] }]}\n          >\n            <Odometer\n              value={perMonth}\n              decimals={2}\n              prefix={currency}\n              style={{\n                color: colors.text,\n                fontSize: font.size['3xl'],\n                fontWeight: font.weight.heavy,\n              }}\n            />\n            <Text style={{ color: colors.textMuted, fontSize: font.size.md, marginBottom: 6 }}>\n              / month\n            </Text>\n          </View>\n\n          <PressJelly\n            block\n            size=\"lg\"\n            variant={state === 'done' ? 'surface' : 'ember'}\n            loading={state === 'busy'}\n            // Done stays at full contrast (a success, not an unavailable action).\n            onPress={state === 'idle' ? purchase : undefined}\n            accessibilityHint={`Subscribes to the ${plan.title.toLowerCase()} plan`}\n          >\n            {state === 'done'\n              ? 'You’re in'\n              : plan.trialDays\n                ? `Start ${plan.trialDays}-day free trial`\n                : `Subscribe for ${money(plan.price)}`}\n          </PressJelly>\n\n          <Text\n            style={{\n              color: colors.textSubtle,\n              fontSize: font.size.xs,\n              lineHeight: 17,\n              textAlign: 'center',\n            }}\n          >\n            {plan.trialDays\n              ? `Free for ${plan.trialDays} days, then ${money(plan.price)} per ${plan.period}. `\n              : `${money(plan.price)} per ${plan.period}. `}\n            Renews automatically; cancel anytime in your store settings.\n          </Text>\n\n          <View style={[styles.links, { gap: space[4] }]}>\n            {link('Restore purchases', onRestore)}\n            {link('Terms', onTerms)}\n            {link('Privacy', onPrivacy)}\n          </View>\n        </View>\n      </ScrollView>\n    </ConfettiBurst>\n  );\n}\n\nconst styles = StyleSheet.create({\n  fill: { flex: 1 },\n  hero: { height: 240 },\n  heroText: { alignItems: 'center' },\n  close: {\n    position: 'absolute',\n    zIndex: 1,\n    width: minTouchTarget,\n    height: minTouchTarget,\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  feature: { flexDirection: 'row', alignItems: 'center' },\n  check: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },\n  plan: {\n    flexDirection: 'row',\n    alignItems: 'center',\n    minHeight: 72,\n    borderWidth: 1.5,\n  },\n  radio: {\n    width: 22,\n    height: 22,\n    borderRadius: 11,\n    borderWidth: 2,\n    alignItems: 'center',\n    justifyContent: 'center',\n  },\n  radioDot: { width: 10, height: 10, borderRadius: 5 },\n  badge: { paddingHorizontal: 10, paddingVertical: 4 },\n  priceRow: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'center' },\n  links: { flexDirection: 'row', justifyContent: 'center', flexWrap: 'wrap' },\n  link: { minHeight: minTouchTarget, justifyContent: 'center' },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "template",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "screen",
    "web": "live",
    "perf": {
      "budget": "One shader hero (scrolls away with the content) and a looping sheen on the title. Price changes roll digits on the UI thread; confetti runs only for ~3 s after a purchase.",
      "measurements": []
    },
    "a11y": {
      "reducedMotion": "The hero and title sheen hold still, prices change without rolling and no confetti is drawn (handled by each component).",
      "screenReader": "Plans are a radio group (\"Yearly, $39.99 per year, Save 44%, selected\"). The monthly price is read as one value. The button announces which plan it buys and its busy state.",
      "touchTarget": "Plan cards are 72 pt tall; close, links and the main button are at least 44 pt."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { Paywall } from '@/components/kinetik/screens/paywall';\n\n<Paywall\n  plans={[\n    {\n      id: 'annual',\n      title: 'Annual',\n      price: 49.99,\n      period: 'year',\n      badge: 'Best value',\n      trialDays: 7,\n    },\n    { id: 'monthly', title: 'Monthly', price: 6.99, period: 'month' },\n  ]}\n  onPurchase={(plan) => purchases.buy(plan.id)}\n  onRestore={() => purchases.restore()}\n  onClose={() => router.back()}\n  onTerms={() => openURL(TERMS)}\n  onPrivacy={() => openURL(PRIVACY)}\n/>;\n```\n\n`onPurchase` may return a promise: the button shows a spinner until it settles, confetti fires when it resolves and the screen stays put if it rejects.\n\n## When to use\n\n- A dedicated upgrade screen opened from a locked feature or from settings.\n- Two or three plans with one clearly recommended.\n\n## When not to use\n\n- On first launch, before people have seen the product.\n- With store prices you have not localised: pass the store's price and `currency`, or replace the price lines with the store's formatted strings.\n\n## Notes\n\n- The pre-selected plan is the first one with a `badge`. The large price shows the monthly equivalent and rolls when the plan changes.\n- The terms line under the button is generated from the selected plan; check it against your store's requirements.\n- Wire it to your purchase library (RevenueCat, expo-iap, …); the template does not talk to a store.\n"
  },
  "docs": "Prefer `npx kinetik-ui add paywall`: it installs dependencies with expo install, so versions match your Expo SDK."
}
