# Spec 36 — Upgrade / Upsell Modal

**Date:** 2026-05-31  
**Status:** Draft  
**Spec number:** 36  
**Depends on:** `zync-subscription`, `foundation-auth-rbac`, `foundation-design-system`, `app-shell`  
**Referenced by:** spec 33 (upgrade modal section), spec 7 (app-shell trial banner), all module specs that call `useTierGate`

---

## Overview

The Upgrade Modal is the single entry point for self-service tier upgrades triggered anywhere in the app. It appears as a floating `<Dialog>` (no page navigation) whenever:

1. A tier-gated feature is accessed and `useTierGate(minimum).allowed === false` — user calls the returned `upgrade(featureName?)` function.
2. The "Upgrade" CTA in the trial banner (app-shell) is clicked.
3. The "Upgrade plan" button on `/settings/plan` is clicked (secondary; that page also has the full comparison table — this modal is the lightweight fast path).
4. Any "Business" / "Enterprise" badge pill in usage stats on `/settings/plan` is clicked.

The modal is **not** a page navigation. It is a globally mounted component in the app shell, controlled by `UpgradeModalContext`. Closing it (Escape, backdrop click, ✕ button) returns the user to exactly where they were.

The full plan management page at `/settings/plan` (spec 33) remains the canonical home for billing details, invoice history, and cancellation. This modal is focused solely on the upgrade decision moment.

---

## Scope

In scope:
- Modal UI: tier cards, feature comparison, monthly/annual toggle
- Context-aware header line ("You need Business to use [feature]")
- Self-service checkout handoff (`POST /api/zync-subscription/checkout`)
- NullAdapter fallback ("Contact us" for all paid tiers)
- Enterprise / White Label "Contact us" path (no self-service)
- Role gate: non-OWNER/ADMIN users see "Ask your admin" screen
- Post-checkout success toast (return from `?upgrade=success`)
- RTL / Hebrew layout

Out of scope:
- New DB tables (none required)
- New API endpoints (uses existing `POST /api/zync-subscription/checkout` from spec 33)
- Full plan management page (spec 33)
- Trial banner itself (spec 7 app-shell owns the banner; the banner calls `upgrade()`)

---

## Component API

### `<UpgradeModal />`

Mounted once in the app shell root. Reads state from `UpgradeModalContext`. Takes no props — all state managed via context.

```tsx
// packages/ui/src/upgrade-modal/UpgradeModal.tsx
export function UpgradeModal(): JSX.Element | null
```

### `useUpgradeModal()`

```tsx
interface UpgradeModalContext {
  /** Open the modal, optionally pre-seeding context. */
  open(opts?: UpgradeModalOpts): void
  /** Close the modal unconditionally. */
  close(): void
  /** True when modal is mounted and visible. */
  isOpen: boolean
}

interface UpgradeModalOpts {
  /**
   * Human-readable feature name shown as a context line at the top of the modal.
   * e.g. "AI Assistant", "Custom Domain", "API Access"
   * When omitted, the context line is not shown.
   */
  featureName?: string
  /**
   * Pre-select a tier card. Defaults to the next tier above the tenant's current tier.
   * e.g. if tenant is 'freelancer', default selection is 'business'.
   */
  targetTier?: TenantTier
}

export function useUpgradeModal(): UpgradeModalContext
```

### `useTierGate()` integration

Spec 3 (foundation-auth-rbac) defines `useTierGate`. The `upgrade()` function returned by `useTierGate` calls `useUpgradeModal().open()` internally:

```tsx
// packages/auth/src/tier-gate.ts  (spec 3 — no change needed to signature)
function useTierGate(minimum: TenantTier): { allowed: boolean; upgrade: (featureName?: string) => void }

// Implementation detail (spec 36 adds this wiring):
const { open } = useUpgradeModal()
const upgrade = (featureName?: string) => open({ featureName, targetTier: minimum })
```

Callers at feature gate points:

```tsx
// Example — AI Assistant gate
const { allowed, upgrade } = useTierGate('business')
if (!allowed) {
  return <Button onClick={() => upgrade('AI Assistant')}>Upgrade</Button>
}
```

```tsx
// Example — Custom Domain gate
const { allowed, upgrade } = useTierGate('enterprise')
if (!allowed) {
  return <LockedBadge onClick={() => upgrade('Custom Domain')} tier="enterprise" />
}
```

---

## State Machine

```
CLOSED
  │
  │  open(opts?)
  ▼
OPEN
  │  State: { opts, selectedTier, period }
  │  selectedTier = opts.targetTier ?? nextTierAboveCurrent
  │  period = 'monthly'
  │
  ├──[user selects tier card]────────────────────────────────────► TIER_SELECTED
  │                                                                  (re-render only; same OPEN state,
  │                                                                   selectedTier updated)
  │
  ├──[selectedTier is enterprise|white_label]──────────────────────► CONTACT_US_VIEW
  │  or [NullAdapter + any paid tier]                                (CTA = mailto link; no redirect)
  │
  ├──[selectedTier is freelancer (current)]────────────────────────► (CTA disabled / "Current plan")
  │
  ├──[user clicks "Upgrade to Business"]
  │    POST /api/zync-subscription/checkout { tier, period }
  │    on error ────────────────────────────────────────────────────► OPEN (toast error, stay open)
  │    on success { checkoutUrl }
  │      checkoutUrl == null ──────────────────────────────────────► OPEN (NullAdapter fallback;
  │                                                                   should not reach here if
  │                                                                   NullAdapter detection is done
  │                                                                   client-side first)
  │      checkoutUrl != null ──────────────────────────────────────► CHECKOUT_REDIRECT
  │                                                                   window.location.href = checkoutUrl
  │
  ├──[Escape / backdrop click / ✕ button]──────────────────────────► CLOSED
  │
  └──[non-OWNER/ADMIN role detected on open]───────────────────────► ASK_ADMIN_VIEW
                                                                      (read-only; no CTAs)

CHECKOUT_REDIRECT
  │  (browser navigates away)
  │
  │  On return to /settings/plan?upgrade=success
  ▼
SUCCESS
  │  close modal (if still mounted)
  │  show success toast: "Plan upgraded! Your new plan is active."
  │  refetch subscription status via GET /api/zync-subscription
  └──► CLOSED
```

---

## ASCII Wireframe

Modal width: 640px (desktop) / full-width bottom sheet (mobile ≤ 640px).  
Direction: RTL (Hebrew). `dir="rtl"` on modal root.

```
┌─────────────────────────────────────────────────────────────────┐
│  [✕]                                                            │  ← close button, inline-end
│                                                                 │
│  שדרג את התוכנית שלך              ← modal title (h2, ink)       │
│                                                                 │
│  ╔══════════════════════════════════════════════════════════╗  │
│  ║  ⚡ תצטרך Business כדי להשתמש ב-AI Assistant           ║  │  ← context line (accent-soft bg,
│  ╚══════════════════════════════════════════════════════════╝  │    accent ink, shown if featureName)
│                                                                 │
│  [  חודשי  ] [  שנתי — חסוך 17%  ]    ← period toggle         │
│                                                                 │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │  Freelancer  │  │   Business   │  │  Enterprise  │         │
│  │              │  │  ★ מומלץ     │  │              │         │
│  │  חינם        │  │  89 ₪/חודש   │  │  צור קשר     │         │
│  │              │  │              │  │              │         │
│  │  ✓ לקוח 1    │  │  ✓ לקוח 1    │  │  ✓ לקוח 1    │         │
│  │  ✓ מודולים   │  │  ✓ כל המודול │  │  ✓ כל המודול │         │
│  │    בסיסיים   │  │  ✓ AI אסיסטנ │  │  ✓ AI אסיסטנ │         │
│  │  — AI        │  │  — ברנד לבן  │  │  ✓ ברנד לבן  │         │
│  │  — דומיין    │  │  — דומיין    │  │  ✓ דומיין    │         │
│  │  — API       │  │  — API       │  │  ✓ API       │         │
│  │              │  │              │  │              │         │
│  │  תוכנית נוכח │  │  [שדרג ל     │  │  [צור קשר →] │         │
│  │  (disabled)  │  │   Business]  │  │              │         │
│  └──────────────┘  └──────────────┘  └──────────────┘         │
│                       ↑ selected (ring border)                  │
│                                                                 │
│  [ראה פרטי תוכנית מלאים ←]         ← link to /settings/plan    │
└─────────────────────────────────────────────────────────────────┘
```

**ASK_ADMIN_VIEW** (non-OWNER/ADMIN):
```
┌─────────────────────────────────────────────────────────────────┐
│  [✕]                                                            │
│                                                                 │
│  שדרג את התוכנית שלך                                            │
│                                                                 │
│  ╔══════════════════════════════════════════════════════════╗  │
│  ║  ⚡ תצטרך Business כדי להשתמש ב-AI Assistant           ║  │
│  ╚══════════════════════════════════════════════════════════╝  │
│                                                                 │
│  רק בעל חשבון או מנהל יכולים לשדרג את התוכנית.                 │
│  פנה למנהל המערכת שלך לשדרוג.                                   │
│                                                                 │
│  [סגור]                                                         │
└─────────────────────────────────────────────────────────────────┘
```

---

## Tier Cards

### Card Data

```ts
interface TierCardData {
  tier: TenantTier
  displayName: string          // Hebrew display name
  price: {
    monthly: number | null     // ILS; null = contact / free
    annual: number | null      // ILS per month (annual billing); null = contact / free
    annualBilledAs: number | null  // total charged (10 payments); null if not applicable
  }
  recommended: boolean
  features: TierFeatureLine[]
}

interface TierFeatureLine {
  label: string                // Hebrew
  available: boolean           // true = ✓, false = —
  highlight?: boolean          // true = visually highlighted on this tier (differentiator vs lower)
}
```

### Card Definitions

```ts
const TIER_CARDS: TierCardData[] = [
  {
    tier: 'freelancer',
    displayName: 'Freelancer',
    price: { monthly: 0, annual: 0, annualBilledAs: null },
    recommended: false,
    features: [
      { label: 'חבר צוות 1', available: true },
      { label: 'מודולים בסיסיים', available: true },
      { label: '1 GB אחסון', available: true },
      { label: 'AI אסיסטנט', available: false },
      { label: 'ברנד לבן', available: false },
      { label: 'דומיין מותאם', available: false },
      { label: 'גישת API', available: false },
      { label: 'תמיכה', available: true, highlight: false },
    ],
  },
  {
    tier: 'business',
    displayName: 'Business',
    price: { monthly: 89, annual: 74, annualBilledAs: 740 },
    recommended: true,
    features: [
      { label: 'עד 8 חברי צוות', available: true, highlight: true },
      { label: 'כל המודולים', available: true, highlight: true },
      { label: '20 GB אחסון', available: true, highlight: true },
      { label: 'AI אסיסטנט', available: true, highlight: true },
      { label: 'ברנד לבן', available: false },
      { label: 'דומיין מותאם', available: false },
      { label: 'גישת API', available: false },
      { label: 'תמיכה: אימייל, 48 ש׳', available: true, highlight: true },
    ],
  },
  {
    tier: 'enterprise',
    displayName: 'Enterprise',
    price: { monthly: null, annual: null, annualBilledAs: null },  // contact only
    recommended: false,
    features: [
      { label: 'עד 15 חברי צוות', available: true, highlight: true },
      { label: 'כל המודולים', available: true },
      { label: '100 GB אחסון', available: true, highlight: true },
      { label: 'AI אסיסטנט', available: true },
      { label: 'ברנד לבן', available: true, highlight: true },
      { label: 'דומיין מותאם', available: true, highlight: true },
      { label: 'גישת API', available: true, highlight: true },
      { label: 'תמיכה: עדיפות, 4 ש׳', available: true, highlight: true },
    ],
  },
]
```

White Label is not shown as a card in the modal. Enterprise users who want White Label contact the sales team.

### Card States

| State | Visual treatment |
|-------|-----------------|
| Current tier | Outlined with `--ink-soft` border, "תוכנית נוכחית" label, CTA disabled |
| Selected (not current) | Outlined with `--accent` 2px ring, elevated shadow |
| Recommended | "★ מומלץ" badge, `--accent-soft` background on badge |
| Hover (non-selected) | `--surface` background darkens by `oklch(...l - 0.03)` |

---

## Monthly / Annual Toggle

```
[  חודשי  ] [  שנתי — חסוך 17%  ]
```

- Default: `monthly`
- Annual badge: `oklch(50% 0.18 145)` (green, same token as success states) — "חסוך 17%"
- Annual pricing: Business 74 ₪/חודש (10 תשלומים = 740 ₪/שנה); Enterprise: contact
- Price display animates (fade crossfade, 150ms) when toggle switches
- "Save 17%" calculation: `(89 - 74) / 89 ≈ 16.85%` — displayed as "17%"

Price display per card:

```
Business (monthly):   89 ₪ / חודש
Business (annual):    74 ₪ / חודש   ← small line below: "חיוב שנתי: 740 ₪ (10 תשלומים)"
Enterprise:           "צור קשר" regardless of toggle
Freelancer:           "חינם לתמיד"
```

---

## CTA Logic

The CTA at the bottom of each card is determined as follows:

```ts
type CtaVariant =
  | 'current'          // disabled — tenant already on this tier
  | 'downgrade'        // disabled — lower than current (Freelancer shown to Business+ users)
  | 'checkout'         // self-service upgrade → checkout
  | 'contact'          // enterprise/white_label OR NullAdapter fallback
  | 'null_adapter'     // NullAdapter: same as contact but different label

function resolveCtaVariant(
  cardTier: TenantTier,
  currentTier: TenantTier,
  isNullAdapter: boolean,
): CtaVariant {
  if (cardTier === currentTier) return 'current'
  if (tierRank(cardTier) < tierRank(currentTier)) return 'downgrade'
  if (cardTier === 'enterprise' || cardTier === 'white_label') return 'contact'
  if (isNullAdapter) return 'null_adapter'
  return 'checkout'
}

// Tier rank for ordering
const TIER_RANK: Record<TenantTier, number> = {
  freelancer: 0,
  business: 1,
  enterprise: 2,
  white_label: 3,
}
function tierRank(t: TenantTier): number { return TIER_RANK[t] }
```

CTA labels:

| Variant | Label (Hebrew) | Behavior |
|---------|---------------|----------|
| `current` | תוכנית נוכחית | Disabled button |
| `downgrade` | — | Button hidden |
| `checkout` | שדרג ל-[DisplayName] | Calls checkout flow |
| `contact` | צור קשר עם מכירות → | Opens `mailto:sales@zync.is?subject=...` |
| `null_adapter` | צור קשר כדי לשדרג → | Same mailto; provider not yet configured |

**Non-OWNER/ADMIN roles:** all CTA variants replaced by "Ask admin" view (see ASK_ADMIN_VIEW wireframe). No CTA buttons rendered.

---

## Checkout Flow

```ts
async function handleCheckout(tier: TenantTier, period: 'monthly' | 'annual') {
  setLoading(true)
  try {
    const res = await fetch('/api/zync-subscription/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ tier, period }),
    })
    if (!res.ok) throw new Error(await res.text())
    const { checkoutUrl } = await res.json()
    if (!checkoutUrl) {
      // NullAdapter: server returned null URL (shouldn't reach here if client-side
      // null_adapter detection is correct, but guard anyway)
      toast.error('ספק התשלום אינו מוגדר — צור קשר עם התמיכה')
      return
    }
    window.location.href = checkoutUrl
  } catch (err) {
    toast.error('שגיאה ביצירת הזמנה — נסה שוב או צור קשר עם התמיכה')
  } finally {
    setLoading(false)
  }
}
```

### NullAdapter detection (client-side)

`isNullAdapter` is derived from the subscription record returned by `GET /api/zync-subscription`:

```ts
// zync_subscriptions.adapter === 'null' → NullAdapter
const isNullAdapter = subscription?.adapter === 'null'
```

When `isNullAdapter === true`, all paid tier CTAs show `null_adapter` variant (contact link) — the checkout POST is never called.

---

## Post-Checkout Return

Payment provider redirects back to `/settings/plan?upgrade=success` (or `?upgrade=cancel` on abort).

This logic lives in the `/settings/plan` page (spec 33), not in the modal itself:

```tsx
// apps/zync-app/src/routes/settings/plan.tsx
useEffect(() => {
  const params = new URLSearchParams(location.search)
  if (params.get('upgrade') === 'success') {
    toast.success('התוכנית שודרגה בהצלחה! התוכנית החדשה שלך פעילה.')
    queryClient.invalidateQueries(['zync-subscription'])
    // Remove query param without navigation
    history.replaceState({}, '', location.pathname)
  }
}, [])
```

The modal is closed at this point (user navigated away). The success state is handled by the return page, not modal state.

---

## Contact Us Path

For `enterprise` and `null_adapter` variants, the CTA is a link:

```ts
function buildContactMailto(featureName?: string, targetTier?: TenantTier): string {
  const subject = encodeURIComponent(
    targetTier === 'enterprise'
      ? `בקשת שדרוג ל-Enterprise${featureName ? ` — ${featureName}` : ''}`
      : `בקשת שדרוג לתוכנית${featureName ? ` — ${featureName}` : ''}`,
  )
  return `mailto:sales@zync.is?subject=${subject}`
}
```

Link opens in same tab (`<a href={mailto}>`). Modal does not auto-close on contact link click — user may want to copy the email address instead of their mail client opening.

---

## Role Check

Role is read from the session context (spec 3):

```ts
const { role } = useCurrentMember()  // { role: TenantRole }
const canUpgrade = role === 'OWNER' || role === 'ADMIN'
```

When `canUpgrade === false`:
- Modal opens normally (it must show the feature context line so the user understands what they're missing)
- Tier cards rendered in read-only (no selected state, no CTA buttons)
- Footer area replaced with:
  ```
  רק בעל חשבון או מנהל יכולים לשדרג.
  פנה למנהל המערכת שלך.
  [סגור]
  ```
- "See full plan details" link still shown (read access to `/settings/plan` is unrestricted)

---

## UpgradeModalProvider

```tsx
// packages/ui/src/upgrade-modal/UpgradeModalProvider.tsx

interface UpgradeModalState {
  isOpen: boolean
  opts: UpgradeModalOpts | null
}

const UpgradeModalContext = createContext<{
  state: UpgradeModalState
  open(opts?: UpgradeModalOpts): void
  close(): void
} | null>(null)

export function UpgradeModalProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<UpgradeModalState>({ isOpen: false, opts: null })

  const open = useCallback((opts?: UpgradeModalOpts) => {
    setState({ isOpen: true, opts: opts ?? null })
  }, [])

  const close = useCallback(() => {
    setState({ isOpen: false, opts: null })
  }, [])

  return (
    <UpgradeModalContext.Provider value={{ state, open, close }}>
      {children}
      <UpgradeModal />
    </UpgradeModalContext.Provider>
  )
}

export function useUpgradeModal() {
  const ctx = useContext(UpgradeModalContext)
  if (!ctx) throw new Error('useUpgradeModal must be used within UpgradeModalProvider')
  return { isOpen: ctx.state.isOpen, open: ctx.open, close: ctx.close }
}
```

`UpgradeModalProvider` wraps the app root in `apps/zync-app/src/main.tsx`, inside `TenantProvider` and `AuthProvider` (both required for role/tier reads).

---

## App Shell Integration

In `apps/zync-app/src/root-layout.tsx` (spec 7 app shell):

```tsx
// Trial banner "Upgrade" button
<Button variant="ghost" size="sm" onClick={() => open()}>
  שדרג עכשיו →
</Button>
```

The `UpgradeModalProvider` is the parent; `open()` from `useUpgradeModal()` is called with no `featureName` (no feature context for general trial upgrade).

---

## Internationalisation

All user-visible strings are in the `he` namespace (`packages/i18n/locales/he/upgrade-modal.json`). English fallback provided in `en/upgrade-modal.json`.

Key structure:

```json
{
  "title": "שדרג את התוכנית שלך",
  "context_line": "תצטרך {{tier}} כדי להשתמש ב-{{feature}}",
  "period_monthly": "חודשי",
  "period_annual": "שנתי — חסוך 17%",
  "annual_billed_as": "חיוב שנתי: {{amount}} ₪ (10 תשלומים)",
  "cta_current": "תוכנית נוכחית",
  "cta_upgrade": "שדרג ל-{{tier}}",
  "cta_contact": "צור קשר עם מכירות →",
  "cta_null_adapter": "צור קשר כדי לשדרג →",
  "ask_admin_title": "רק מנהל יכול לשדרג",
  "ask_admin_body": "רק בעל חשבון או מנהל יכולים לשדרג את התוכנית. פנה למנהל המערכת שלך.",
  "close": "סגור",
  "see_full_plan": "ראה פרטי תוכנית מלאים",
  "recommended_badge": "★ מומלץ",
  "current_plan_badge": "תוכנית נוכחית",
  "price_free": "חינם לתמיד",
  "price_contact": "צור קשר",
  "error_checkout": "שגיאה ביצירת הזמנה — נסה שוב",
  "error_no_provider": "ספק התשלום אינו מוגדר — צור קשר עם התמיכה"
}
```

---

## Design Tokens Applied

| Element | Token |
|---------|-------|
| Modal backdrop | `oklch(0% 0 0 / 40%)` |
| Modal surface | `--surface` |
| Modal border radius | `--radius` (`4px`) |
| Heading ink | `--ink` |
| Body text | `--ink-soft` |
| Context line background | `--accent-soft` |
| Context line text | `--accent` |
| Selected card ring | `--accent` 2px solid |
| Recommended badge bg | `--accent-soft` |
| Recommended badge text | `--accent` |
| Primary CTA button | `--accent` bg, white text |
| Period toggle active | `--accent` bg, white text |
| Period toggle inactive | `--surface`, `--ink-soft` text |
| Save badge (annual) | `oklch(50% 0.18 145)` (success green) |
| Feature tick (✓) | `oklch(50% 0.18 145)` |
| Feature dash (—) | `--ink-soft` at 40% opacity |
| Highlighted feature row | `--accent-soft` left/right border highlight (4px, accent) |

Spacing: all padding/gap values from 8px grid (8, 16, 24, 32px).

---

## File Locations

```
packages/ui/src/upgrade-modal/
  UpgradeModal.tsx          ← main modal component
  UpgradeModalProvider.tsx  ← context provider + hook
  TierCard.tsx              ← single tier card
  PeriodToggle.tsx          ← monthly/annual toggle
  tier-card-data.ts         ← TIER_CARDS constant
  cta-logic.ts              ← resolveCtaVariant, buildContactMailto
  index.ts                  ← re-exports

packages/i18n/locales/
  he/upgrade-modal.json
  en/upgrade-modal.json
```

The `UpgradeModalProvider` import is added to `apps/zync-app/src/main.tsx`.

The `useTierGate` wiring (calling `open()` from context) is added to `packages/auth/src/tier-gate.ts` (spec 3 file — additive change only, no breaking changes).

---

## Design Decisions

| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Modal, not page navigation | User triggered a feature they want to use. Sending them to a separate page breaks flow. Modal lets them upgrade and return to the exact spot. |
| 2 | 3 tier cards (no White Label) | White Label is a partner arrangement, not a self-service product. Showing it in a consumer modal would confuse. Enterprise → "Contact us" is the ceiling for in-app discovery. |
| 3 | `upgrade(featureName?)` optional arg | All existing `useTierGate` call sites remain valid without changes. Feature name is a UX enhancement, not a required parameter. |
| 4 | NullAdapter detection client-side | Avoids a checkout round-trip that will fail. Read `subscription.adapter` once on modal open; all CTA rendering is deterministic. |
| 5 | Contact mailto, not a form | A contact form requires a backend endpoint, CSRF, email infra. mailto costs nothing, works offline, and is recoverable if the user's mail client fails. A proper contact form is a future marketing-site feature. |
| 6 | ASK_ADMIN_VIEW still shows feature context | The non-owner user still needs to understand WHY they opened the modal (which feature they tried). They should be able to tell their admin "I need Business for AI Assistant" — the context line enables that. |
| 7 | Success toast on `/settings/plan?upgrade=success` — not on modal return | After checkout redirect the browser is at `/settings/plan`. The modal is no longer mounted. Toast on the return page is simpler and more reliable than trying to persist state across navigation. |
| 8 | Monthly default, not annual | Reduces friction at the upgrade moment. Annual is the upsell within the upsell — show it clearly with "Save 17%" badge but don't force users to reason about annual commitment at peak intent. |
| 9 | `UpgradeModalProvider` wraps app root | The provider must be available everywhere — deep in any module, behind any route. Root-level mounting is the only guaranteed option. `<Dialog>` is rendered in a Portal so z-index is unaffected by nesting. |
| 10 | No new API endpoints | `POST /api/zync-subscription/checkout` from spec 33 is sufficient. The modal is a pure UI layer that calls existing infrastructure. |
| 11 | Annual = 10 payments, not 12 | Aligned with spec 33 pricing: Business 740 ILS annual = 74/mo × 10. This is a commercial decision (2 months free) already established in spec 33. Modal displays it accurately. |
| 12 | Feature rows order consistent across all cards | Same row order for all three cards ensures horizontal scanning works at a glance. Users compare features by scanning left–right on a row, not top–bottom on a card. |
