# Trial Expiry & Conversion UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-trial-expiry-conversion-ui.md  ·  **Slug:** trial-expiry-conversion-ui  ·  **Wave:** 5
**Depends on:** app-shell, foundation-auth-rbac, upgrade-upsell-modal, zync-subscription

## Goal
Deliver the tenant-facing UX for the full Zync trial lifecycle: active-trial banner styling, an "ending soon" urgency card (≤ 3 days), a one-time "trial expired" interstitial with a downgrade notice, a post-conversion success toast, and a locked-state treatment for previously-configured Business+ features after downgrade. The subscription data model, trial period logic, grace period, and the daily expiry cron are owned upstream by `zync-subscription`; this spec owns the app-shell UI integration, two milestone-email timing flags, the T-3/T-1 cron notification milestones, and the acknowledgement of the expiry interstitial.

## Architecture
This spec is a thin UI + UX-timing layer over `zync-subscription`. It consumes the upstream `zync_subscriptions` table (tier, status, trial_ends_at, grace_period_started_at, adapter, canceled_at) and adds two timing-flag columns to it via `ALTER TABLE` (not a new table). Data flow:

1. **Server source of truth:** `GET /api/zync-subscription` (upstream route) returns the subscription record. This plan extends its response to expose `trial_warning_sent_at` and `trial_expiry_ack_at` so the client can derive state without extra round-trips.
2. **Client state derivation:** a single `useTrialState()` hook maps the subscription record → a `TrialUiState` enum. It layers ON TOP of upstream banner states (grace period and past_due are owned by `zync-subscription`'s app-shell banners) so this spec never double-renders those. Precedence (highest first): `past_due` (upstream) → `grace` (upstream) → `expired` (this spec's interstitial) → `ending_soon` (this spec's card) → `trial_active` (upstream `TrialBanner`, this spec restyles) → `none`.
3. **Banner rendering:** the upstream `TrialBanner` component (locked: `TrialBanner`, `TrialBannerProps`) renders the active-trial state; this spec passes a derived urgency variant. The "ending soon" card and the "expired" interstitial are NEW components owned by this spec (additive, never mutating the locked component).
4. **Acknowledgement:** "Continue with Freelancer plan" calls a NEW endpoint `POST /api/zync-subscription/ack-trial-expiry`, which stamps `trial_expiry_ack_at = now()`, so the interstitial shows only once.
5. **Cron milestones:** the existing `POST /api/cron/subscription-trial-check` handler (upstream) is MODIFIED to emit T-3 and T-1 email/notification milestones via `notifyTenant`, guarded by `trial_warning_sent_at`.
6. **Conversion:** post-checkout return to `/settings/plan?upgrade=success` (handled upstream) additionally shows this spec's one-time "Welcome to Business!" toast and removes any mounted interstitial; the upstream `useSubscription` query refetch clears banner states.

Upstream tables consumed: `zync_subscriptions` (owner: zync-subscription), `tenants` (`tenants.tier`, synced via `syncTierToTenant`). Upstream exports consumed: `TrialBanner`, `TrialBannerProps`, `useSubscription`, `useUpgradeModal`, `ZyncSubscriptionRow`, `ZyncSubscriptionStatus`, `TenantTier`, `requireTier`, `useTierGate`, `authMiddleware`, `requirePermission`, `tenantQuery`, `toast`, `Toaster`, `Dialog`, `DialogProps`, `Button`, `Badge`, `Card`, `Alert`, `createNotification`, `sendEmail`, `LocaleProvider`, `useDirection`, `translations`.

## Tech Stack
- **App:** `apps/zync-app` (Vite + React, Cloudflare Workers) — banner restyle, ending-soon card, interstitial, conversion toast, locked-state component.
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — extend `GET /api/zync-subscription` response, add `POST /api/zync-subscription/ack-trial-expiry`, modify `POST /api/cron/subscription-trial-check`.
- **Packages:** `@zync/db` (Drizzle schema column additions + migration), `@zync/ui` (new presentational components), `@zync/types` (TrialUiState type), `@zync/i18n` (he/en strings), `@zync/notifications` (`createNotification`, `sendEmail` reuse).
- **Bindings:** Hyperdrive (Neon Postgres), the existing cron trigger that hits `/api/cron/subscription-trial-check`, EmailNotificationAdapter via `@zync/notifications`.
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM. No new tables.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 5a | Task 1 (schema/migration), Task 2 (types) | `packages/db/src/schema/zync-subscriptions.ts`, migration, `packages/types/src/trial.ts` | Task 1 & 2 parallel |
| 5b | Task 3 (GET extend), Task 4 (ack endpoint), Task 5 (cron milestones) | `apps/zync-api/src/routes/zync-subscription.ts`, `apps/zync-api/src/cron/subscription-trial-check.ts` | After 5a; 3/4/5 parallel |
| 5c | Task 6 (i18n), Task 7 (useTrialState hook) | `packages/i18n/locales/{he,en}/trial-expiry.json`, `apps/zync-app/src/hooks/use-trial-state.ts` | After 5a/5b; parallel |
| 5d | Task 8 (banner restyle), Task 9 (ending-soon card), Task 10 (interstitial), Task 11 (locked-state) | `packages/ui/src/trial/*`, `apps/zync-app/src/root-layout.tsx` | After 5c; 8/9/10/11 parallel |
| 5e | Task 12 (conversion toast wiring), Task 13 (a11y/RTL/reduced-motion verification) | `apps/zync-app/src/routes/settings/plan.tsx`, `apps/zync-app/src/root-layout.tsx` | After 5d, sequential |

## Tasks

### Task 1: Add trial-timing columns to `zync_subscriptions`
**Blocks:** 3, 4, 5, 7  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/zync-subscriptions.ts`
- Create: `packages/db/migrations/<timestamp>_trial_expiry_columns.sql`
**Steps:**
- [ ] Add two nullable TIMESTAMPTZ columns to the existing `zyncSubscriptions` Drizzle table definition (owned by zync-subscription) — do NOT redefine the table or its existing columns (`grace_period_started_at`, `trial_ends_at`, etc. stay upstream).
- [ ] Write the forward migration with the two `ALTER TABLE` statements below.
- [ ] Confirm Drizzle column names map to snake_case DB columns (`trialWarningSentAt` → `trial_warning_sent_at`, `trialExpiryAckAt` → `trial_expiry_ack_at`).
**Schema / Interfaces:**
```sql
-- Migration: trial-expiry timing flags on the upstream zync_subscriptions table.
-- zync_subscriptions itself (PK, tier/status CHECKs, trial_ends_at, grace_period_started_at)
-- is owned by spec zync-subscription. This migration ONLY adds the two columns below.
ALTER TABLE zync_subscriptions ADD COLUMN trial_warning_sent_at TIMESTAMPTZ;
ALTER TABLE zync_subscriptions ADD COLUMN trial_expiry_ack_at   TIMESTAMPTZ;
-- trial_warning_sent_at: NULL = no milestone email sent yet; stamped at first T-3/T-1 send.
-- trial_expiry_ack_at:   NULL = expiry interstitial not yet acknowledged; stamped on
--                        "Continue with Freelancer plan".
```
```ts
// packages/db/src/schema/zync-subscriptions.ts — ADD to the existing pgTable definition:
//   trialWarningSentAt: timestamp('trial_warning_sent_at', { withTimezone: true }),
//   trialExpiryAckAt:   timestamp('trial_expiry_ack_at',   { withTimezone: true }),
```
**Acceptance:**
- [ ] `drizzle-kit` generates no destructive diff; migration applies cleanly against Neon.
- [ ] Both columns are nullable TIMESTAMPTZ; no new table is created.
- [ ] `ZyncSubscriptionRow` type (upstream) reflects the two new optional fields after schema regen.

### Task 2: `TrialUiState` type + state-derivation contract
**Blocks:** 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/trial.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define the `TrialUiState` union and the input shape consumed by the derivation hook.
- [ ] Re-export from the `@zync/types` barrel.
**Schema / Interfaces:**
```ts
// packages/types/src/trial.ts
// 'past_due' and 'grace' are derived purely so this layer can YIELD precedence to the
// upstream zync-subscription banners — this spec renders UI only for 'ending_soon',
// 'expired', 'trial_active', and 'none'.
export type TrialUiState =
  | 'none'          // not trialing, not expired, not grace, not past_due
  | 'trial_active'  // status='trialing', trial_ends_at > now()+3d
  | 'ending_soon'   // status='trialing', now() < trial_ends_at <= now()+3d
  | 'grace'         // upstream-owned: grace_period_started_at IS NOT NULL (yield)
  | 'past_due'      // upstream-owned: status='past_due' (yield)
  | 'expired';      // status='active', tier='freelancer', trial_ends_at set, ack not stamped

export interface TrialStateInput {
  status: ZyncSubscriptionStatus;        // upstream union: 'active'|'trialing'|'past_due'|'canceled'
  tier: TenantTier;                      // 'freelancer'|'business'|'enterprise'|'white_label'
  trialEndsAt: string | null;            // ISO; upstream trial_ends_at
  gracePeriodStartedAt: string | null;   // upstream grace_period_started_at
  trialExpiryAckAt: string | null;       // this spec's column
  now: number;                           // Date.now() injected for testability
}

export interface TrialStateResult {
  state: TrialUiState;
  daysLeft: number | null;     // ceil((trialEndsAt - now)/86_400_000) for trial states; null otherwise
  urgent: boolean;             // true when state==='ending_soon' (drives amber+bold banner styling)
}
```
**Acceptance:**
- [ ] Types compile and are exported from `@zync/types`.
- [ ] `ZyncSubscriptionStatus` and `TenantTier` are imported from existing `@zync/types`, not redefined.

### Task 3: Extend `GET /api/zync-subscription` response with trial flags
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/zync-subscription.ts`
**Steps:**
- [ ] In the existing `GET /api/zync-subscription` handler (upstream), include `trial_warning_sent_at` and `trial_expiry_ack_at` in the selected/serialized fields.
- [ ] Keep the existing `OWNER`/`ADMIN` auth + `authMiddleware` and the existing `storage` block untouched.
- [ ] Serialize timestamps as ISO strings (or null).
**Schema / Interfaces:**
```ts
// Response shape ADDITIONS (existing fields preserved):
// {
//   ...existing subscription fields (tier, status, trial_ends_at, grace_period_started_at,
//      adapter, canceled_at, period, current_period_end, storage, ...),
//   trial_warning_sent_at: string | null,
//   trial_expiry_ack_at:   string | null,
// }
```
**Acceptance:**
- [ ] `GET /api/zync-subscription` returns both new fields for the session tenant.
- [ ] Route still requires an authenticated `OWNER`/`ADMIN` session (unchanged).
- [ ] No change to the existing `storage` sub-object or other fields.

### Task 4: New endpoint `POST /api/zync-subscription/ack-trial-expiry`
**Blocks:** 10  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/zync-subscription.ts`
**Steps:**
- [ ] Add a `POST /api/zync-subscription/ack-trial-expiry` route under the existing `/api/zync-subscription` group, guarded by `authMiddleware` + `OWNER`/`ADMIN` role.
- [ ] Stamp `trial_expiry_ack_at = now()` for the session tenant's subscription via `tenantQuery` (no raw Drizzle from the route — use the data-access helper pattern).
- [ ] Idempotent: if already stamped, return 200 without re-writing (or overwrite — harmless; prefer a `WHERE trial_expiry_ack_at IS NULL` guard so the first ack timestamp is preserved).
- [ ] Validate the (empty) body with a zod schema per `require-zod-validation-in-routes`.
- [ ] Return `{ ok: true, trial_expiry_ack_at }`.
**Schema / Interfaces:**
```ts
// POST /api/zync-subscription/ack-trial-expiry  (auth: OWNER|ADMIN)
// body: {} (empty; validated by ackTrialExpirySchema = z.object({}).strict())
// effect: UPDATE zync_subscriptions
//         SET trial_expiry_ack_at = now()
//         WHERE tenant_id = :sessionTenantId AND trial_expiry_ack_at IS NULL;
// 200 -> { ok: true, trial_expiry_ack_at: string }
```
**Acceptance:**
- [ ] Calling the endpoint stamps `trial_expiry_ack_at` exactly once (first call wins).
- [ ] Second call returns 200 and does not change the stored timestamp.
- [ ] Unauthorized / non-OWNER-ADMIN requests are rejected (401/403).
- [ ] Endpoint is registered in the route group and reachable.

### Task 5: Add T-3 / T-1 milestone notifications to the trial-check cron
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/cron/subscription-trial-check.ts`
**Steps:**
- [ ] In the existing daily handler (upstream — already iterates trialing subscriptions and handles grace/downgrade), add a milestone check for rows where `status = 'trialing'` and `trial_ends_at > now()`.
- [ ] Compute `daysUntilEnd = Math.ceil((trial_ends_at - now) / 86_400_000)`.
- [ ] When `daysUntilEnd === 3` or `daysUntilEnd === 1`, and `trial_warning_sent_at` has not already covered this milestone, emit a notification + email via `notifyTenant` / `createNotification` + `sendEmail`, then stamp `trial_warning_sent_at = now()`.
- [ ] Guard so each milestone fires at most once per day-bucket: treat `trial_warning_sent_at` as "last milestone email sent"; only send if `trial_warning_sent_at IS NULL` OR its calendar day differs from today (prevents duplicate sends within the same cron-day for the same milestone).
- [ ] Do NOT alter the existing grace-period / downgrade logic owned by zync-subscription — this is an additive branch.
- [ ] Subjects: T-3 → "Your Zync Business trial ends in 3 days"; T-1 → "Last day of your Business trial". Both bodies include the Business feature list and the payment link (`/settings/plan`).
**Schema / Interfaces:**
```ts
// apps/zync-api/src/cron/subscription-trial-check.ts (ADDITIVE branch, runs before grace/downgrade)
// for (const sub of trialingNotYetExpired) {
//   const daysUntilEnd = Math.ceil((sub.trial_ends_at.getTime() - now) / 86_400_000)
//   if (daysUntilEnd === 3 || daysUntilEnd === 1) {
//     if (!alreadySentToday(sub.trial_warning_sent_at, now)) {
//       await createNotification(...)   // 'trial_expiring', { daysRemaining: daysUntilEnd }
//       await sendEmail({ to: ownerEmail, subject, html })  // subject per daysUntilEnd
//       await db.update(zyncSubscriptions)
//         .set({ trialWarningSentAt: new Date(now) })
//         .where(eq(zyncSubscriptions.tenantId, sub.tenant_id))
//     }
//   }
// }
// Email subjects:
//   daysUntilEnd === 3 -> "Your Zync Business trial ends in 3 days"
//   daysUntilEnd === 1 -> "Last day of your Business trial"
```
**Acceptance:**
- [ ] A trialing tenant 3 days from `trial_ends_at` receives exactly one T-3 notification + email; `trial_warning_sent_at` is stamped.
- [ ] A trialing tenant 1 day from `trial_ends_at` receives exactly one T-1 notification + email.
- [ ] Re-running the cron the same day does not resend the same milestone.
- [ ] Existing grace-period and downgrade behavior is unchanged.

### Task 6: i18n strings (he-first, en fallback)
**Blocks:** 8, 9, 10, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/i18n/locales/he/trial-expiry.json`
- Create: `packages/i18n/locales/en/trial-expiry.json`
**Steps:**
- [ ] Author the `he` namespace first (primary locale), then the `en` fallback with identical keys.
- [ ] Register the `trial-expiry` namespace in the i18n loader if namespaces are explicitly enumerated.
**Schema / Interfaces:**
```json
// en/trial-expiry.json (he/trial-expiry.json mirrors with Hebrew values)
{
  "banner_active": "{{days}} days left in your Business trial",
  "banner_active_cta": "Add payment method →",
  "banner_ending_title": "Your Business trial ends in {{days}} day(s)",
  "ending_subtitle": "Add a payment method to keep access to:",
  "feature_lead_forms": "Lead forms & webhooks",
  "feature_contracts": "Contracts & e-signing",
  "feature_expense_approval": "Expense approval",
  "feature_custom_email": "Custom email domain",
  "feature_api_keys": "API keys",
  "feature_multicurrency": "Multi-currency invoicing",
  "ending_cta": "Start Business Plan — ₪89/month →",
  "ending_reassurance": "No commitment. Cancel anytime.",
  "interstitial_title": "Your Business trial has ended",
  "interstitial_downgraded": "Your account has been downgraded to Freelancer.",
  "interstitial_data_safe": "Your data is safe — you can still access and export it.",
  "interstitial_lost_heading": "Features no longer available:",
  "interstitial_lost_lead_forms": "Lead forms and webhooks",
  "interstitial_lost_contracts": "Contracts and e-signing",
  "interstitial_lost_expense": "Expense approval workflow",
  "interstitial_lost_api": "API keys and webhooks",
  "interstitial_lost_domain": "Custom email domain",
  "interstitial_upgrade_cta": "Upgrade to Business — ₪89/month",
  "interstitial_continue_cta": "Continue with Freelancer plan →",
  "conversion_toast": "Welcome to Business! All features are now unlocked.",
  "locked_tooltip": "Upgrade to access"
}
```
**Acceptance:**
- [ ] Both files have identical key sets; `he` values are Hebrew, `en` values are English.
- [ ] Strings resolve through the existing `translations` / `LocaleProvider` mechanism.

### Task 7: `useTrialState()` derivation hook
**Blocks:** 8, 9, 10  ·  **Blocked by:** 2, 3, 6
**Files:**
- Create: `apps/zync-app/src/hooks/use-trial-state.ts`
**Steps:**
- [ ] Consume the upstream `useSubscription()` query (returns the extended `GET /api/zync-subscription` payload incl. the two new flags).
- [ ] Implement a pure `deriveTrialState(input: TrialStateInput): TrialStateResult` function and the React hook wrapping it with `now = Date.now()`.
- [ ] Apply precedence exactly: `past_due` → `grace` → `expired` → `ending_soon` → `trial_active` → `none` (return `grace`/`past_due` so callers know to yield to upstream banners and render nothing here).
- [ ] `ending_soon` when `status='trialing'` and `0 < daysLeft <= 3`; `trial_active` when `status='trialing'` and `daysLeft > 3`; `expired` when `status='active' && tier='freelancer' && trialEndsAt != null && trialExpiryAckAt == null && gracePeriodStartedAt == null`.
- [ ] Banner not shown when `daysLeft <= 0` for trial states (matches upstream `TrialBanner`).
- [ ] Export both `deriveTrialState` (pure) and `useTrialState` (hook).
**Schema / Interfaces:**
```ts
// apps/zync-app/src/hooks/use-trial-state.ts
export function deriveTrialState(input: TrialStateInput): TrialStateResult;
export function useTrialState(): TrialStateResult;
// Precedence (first match wins):
//   status==='past_due'                                   -> { state:'past_due',  urgent:false, daysLeft:null }
//   gracePeriodStartedAt != null                          -> { state:'grace',     urgent:false, daysLeft:null }
//   status==='active' && tier==='freelancer'
//     && trialEndsAt!=null && trialExpiryAckAt==null      -> { state:'expired',   urgent:false, daysLeft:null }
//   status==='trialing' && 0 < daysLeft <= 3              -> { state:'ending_soon', urgent:true, daysLeft }
//   status==='trialing' && daysLeft > 3                   -> { state:'trial_active', urgent:false, daysLeft }
//   else                                                  -> { state:'none', urgent:false, daysLeft:null }
// daysLeft = Math.ceil((Date.parse(trialEndsAt) - now)/86_400_000)
```
**Acceptance:**
- [ ] Unit-level: each input combination from the spec's Trial States table maps to the correct `TrialUiState`.
- [ ] `grace` and `past_due` are returned (so the app shell knows to defer to upstream banners) but produce no UI from this spec.
- [ ] `daysLeft <= 0` trial rows resolve to `none` (no banner).

### Task 8: Restyle active-trial banner (blue → amber+bold at ≤3d)
**Blocks:** 12  ·  **Blocked by:** 7
**Files:**
- Modify: `apps/zync-app/src/root-layout.tsx`
- Create: `packages/ui/src/trial/trial-banner-variant.ts`
**Steps:**
- [ ] In the app-shell root layout, render the upstream `TrialBanner` only when `useTrialState().state === 'trial_active'`.
- [ ] Pass the derived variant: blue (`--color-info`) by default; when `daysLeft <= 3` use amber (`--color-warning`) with bold text. (Note: `ending_soon` is handled by Task 9's card, which REPLACES the header banner; `trial_active` covers `daysLeft > 3`, so the amber path here is the lower boundary visual only if upstream `TrialBanner` is reused for the 3-day edge — keep the variant util generic.)
- [ ] Provide a small pure helper `trialBannerVariant(daysLeft)` returning `'info' | 'warning'` for token selection; pass to `TrialBanner` via its props (`TrialBannerProps`) without mutating the locked component.
- [ ] The active banner CTA ("Add payment method →") routes to `/settings/plan` (scroll to current plan card), reusing upstream behavior.
- [ ] Banner is dismissible only via conversion/expiry, not a close button (do not render a close affordance).
**Schema / Interfaces:**
```ts
// packages/ui/src/trial/trial-banner-variant.ts
export function trialBannerVariant(daysLeft: number): 'info' | 'warning';
//   daysLeft <= 3 -> 'warning' (amber, bold); else 'info' (blue)
```
**Acceptance:**
- [ ] Active trial > 3 days shows the blue info banner.
- [ ] No close button is rendered on the active-trial banner.
- [ ] CTA navigates to `/settings/plan`.
- [ ] Variant util maps `daysLeft <= 3 → 'warning'`, else `'info'`.

### Task 9: "Trial ending soon" inline card (≤ 3 days)
**Blocks:** 12  ·  **Blocked by:** 7
**Files:**
- Create: `packages/ui/src/trial/TrialEndingSoonCard.tsx`
- Modify: `apps/zync-app/src/root-layout.tsx`
- Modify: `packages/ui/src/index.ts`
**Steps:**
- [ ] Build a prominent inline `Card` (replaces the header banner) shown when `useTrialState().state === 'ending_soon'`.
- [ ] Render the warning icon, title "Your Business trial ends in {X} day(s)", the 6-feature two-column checklist, the primary CTA "Start Business Plan — ₪89/month →", and the reassurance line "No commitment. Cancel anytime."
- [ ] CTA opens the upgrade flow via `useUpgradeModal().open({ targetTier: 'business' })` (upstream modal) — fast path identical to the trial banner's upgrade entry point.
- [ ] Use amber warning tokens (`--color-warning`), 8px-grid spacing, design-system primitives only (no hardcoded colors/spacing/radius per `no-hardcoded-colors`, `no-hardcoded-spacing`, `no-radius-ladder`).
- [ ] All strings via the `trial-expiry` i18n namespace; `dir`-aware via `useDirection` (RTL Hebrew primary).
- [ ] Export `TrialEndingSoonCard` from `@zync/ui`.
**Schema / Interfaces:**
```ts
// packages/ui/src/trial/TrialEndingSoonCard.tsx
export interface TrialEndingSoonCardProps {
  daysLeft: number;
  onUpgrade: () => void;   // wired to useUpgradeModal().open({ targetTier: 'business' })
}
export function TrialEndingSoonCard(props: TrialEndingSoonCardProps): JSX.Element;
```
**Acceptance:**
- [ ] When state is `ending_soon`, the inline card renders and the header `TrialBanner` is not shown simultaneously.
- [ ] Six feature labels render in two columns, matching the spec wireframe.
- [ ] CTA opens the upgrade modal pre-selecting Business.
- [ ] No hardcoded color/spacing/radius literals; RTL layout correct in Hebrew.

### Task 10: "Trial expired" one-time interstitial
**Blocks:** 12  ·  **Blocked by:** 4, 7
**Files:**
- Create: `packages/ui/src/trial/TrialExpiredInterstitial.tsx`
- Modify: `apps/zync-app/src/root-layout.tsx`
- Modify: `packages/ui/src/index.ts`
**Steps:**
- [ ] Build a full-page blocking interstitial (`Dialog`, `aria-modal="true"`, `role="dialog"`, focus trap, labelled by the title) shown when `useTrialState().state === 'expired'`.
- [ ] Render: Zync logo, title "Your Business trial has ended", downgrade notice, data-safe reassurance, the 5-item "Features no longer available" list, the primary "Upgrade to Business — ₪89/month" CTA, and the secondary "Continue with Freelancer plan →" link.
- [ ] "Upgrade to Business" → navigate to `/settings/plan` scrolled to the plan card (upstream upgrade flow), OR open the upgrade modal — match spec §3: navigate to `/settings/plan`.
- [ ] "Continue with Freelancer plan →" → call `POST /api/zync-subscription/ack-trial-expiry`, then dismiss the interstitial and invalidate the `useSubscription` query so it never re-shows (server `trial_expiry_ack_at` now set).
- [ ] Respect `prefers-reduced-motion` for any entrance animation; ensure first focusable element receives focus on mount and focus is trapped within the dialog.
- [ ] Strings via `trial-expiry` namespace; RTL-aware.
- [ ] Export `TrialExpiredInterstitial` from `@zync/ui`.
**Schema / Interfaces:**
```ts
// packages/ui/src/trial/TrialExpiredInterstitial.tsx
export interface TrialExpiredInterstitialProps {
  onUpgrade: () => void;       // navigate to /settings/plan (plan card)
  onAcknowledge: () => void;   // POST /api/zync-subscription/ack-trial-expiry then dismiss
}
export function TrialExpiredInterstitial(props: TrialExpiredInterstitialProps): JSX.Element;
// a11y: role="dialog" aria-modal="true" aria-labelledby=<title id>; focus trap; ESC does
// NOT silently bypass acknowledgement (only the two explicit CTAs leave the state).
```
**Acceptance:**
- [ ] Interstitial appears once on first post-expiry load (when `trial_expiry_ack_at IS NULL`).
- [ ] "Continue with Freelancer" stamps `trial_expiry_ack_at` server-side; on next load the interstitial does not reappear.
- [ ] Dialog has `role="dialog"`, `aria-modal="true"`, is labelled by its title, and traps focus.
- [ ] Entrance animation is suppressed under `prefers-reduced-motion`.

### Task 11: Locked-state treatment for previously-configured Business+ features
**Blocks:** —  ·  **Blocked by:** 7
**Files:**
- Create: `packages/ui/src/trial/LockedFeatureRow.tsx`
- Modify: `packages/ui/src/index.ts`
**Steps:**
- [ ] Build a reusable presentational component for rows of previously-configured Business+ data that are now read-only after downgrade (e.g. webhook endpoint list): renders the row content dimmed, a 🔒 lock icon, and an "Upgrade to access" tooltip.
- [ ] Clicking the lock / row CTA opens the upgrade modal via `useUpgradeModal().open({ featureName })` (caller passes the feature name).
- [ ] No destructive behavior — purely a visual lock overlay; the underlying data is preserved (this spec asserts no cleanup; cron owns no-delete-on-downgrade, already upstream).
- [ ] Tooltip text from the `trial-expiry` namespace (`locked_tooltip`); icon has an accessible label; component is RTL-aware.
- [ ] Export `LockedFeatureRow` from `@zync/ui` so module specs (lead-form-builder, contracts, expenses, custom-smtp, public-api) can wrap their gated rows.
**Schema / Interfaces:**
```ts
// packages/ui/src/trial/LockedFeatureRow.tsx
export interface LockedFeatureRowProps {
  children: React.ReactNode;   // the original row content, rendered dimmed
  featureName: string;         // passed to useUpgradeModal().open({ featureName })
  onUpgrade?: () => void;      // optional override; defaults to useUpgradeModal().open
}
export function LockedFeatureRow(props: LockedFeatureRowProps): JSX.Element;
```
**Acceptance:**
- [ ] Component renders dimmed content + 🔒 icon + "Upgrade to access" tooltip.
- [ ] Clicking opens the upgrade modal with the supplied `featureName`.
- [ ] Icon exposes an accessible label; layout correct in RTL.
- [ ] No data mutation occurs from this component.

### Task 12: Post-conversion success toast + state teardown
**Blocks:** 13  ·  **Blocked by:** 8, 9, 10
**Files:**
- Modify: `apps/zync-app/src/routes/settings/plan.tsx`
- Modify: `apps/zync-app/src/root-layout.tsx`
**Steps:**
- [ ] On return to `/settings/plan?upgrade=success` (upstream already invalidates `['zync-subscription']` and shows the generic upgrade toast), additionally show this spec's one-time trial-conversion toast: "Welcome to Business! All features are now unlocked." (auto-dismiss after 5s) — use spec 68's wording for the trial-conversion path (distinct from upgrade-modal's generic "Plan upgraded!" copy).
- [ ] Guard the toast so it fires once per success return (e.g. clear the `?upgrade=success` query param via `history.replaceState` after showing — matching upstream pattern).
- [ ] On successful conversion (`status` now `active`, `tier` `business`): app-shell `TrialBanner` and ending-soon card disappear immediately (driven by `useTrialState` returning `none`), and any mounted interstitial unmounts (state no longer `expired`).
- [ ] Use the existing `toast` / `Toaster` from `@zync/ui`; respect `prefers-reduced-motion` for toast animation.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/routes/settings/plan.tsx (additive within existing ?upgrade=success effect)
// if (params.get('upgrade') === 'success') {
//   toast.success(t('trial-expiry:conversion_toast'))  // "Welcome to Business! ..."
//   queryClient.invalidateQueries(['zync-subscription'])
//   history.replaceState({}, '', location.pathname)
// }
```
**Acceptance:**
- [ ] After a successful conversion the trial-conversion toast shows exactly once and auto-dismisses after 5s.
- [ ] Banner / ending-soon card / interstitial all disappear once subscription refetch shows `business` + `active`.
- [ ] Refreshing the page after the param is cleared does not re-show the toast.

### Task 13: A11y / RTL / reduced-motion verification pass
**Blocks:** —  ·  **Blocked by:** 12
**Files:**
- Modify: `apps/zync-app/src/root-layout.tsx`
- Modify: `packages/ui/src/trial/TrialExpiredInterstitial.tsx`
- Modify: `packages/ui/src/trial/TrialEndingSoonCard.tsx`
**Steps:**
- [ ] Confirm the interstitial dialog has `role="dialog"`, `aria-modal="true"`, an `aria-labelledby` pointing at the title, a working focus trap, and returns focus to the prior element on close.
- [ ] Confirm all trial UI strings resolve from the `trial-expiry` namespace with Hebrew as the primary locale and correct mirroring under `dir="rtl"` (logical CSS properties, no hardcoded left/right).
- [ ] Confirm all entrance/exit animations (interstitial, ending-soon card, conversion toast) are disabled under `prefers-reduced-motion: reduce`.
- [ ] Confirm warning/amber and info/blue states use design tokens (`--color-warning`, `--color-info`) — no hardcoded color literals.
- [ ] Confirm the lock icon and any icon-only controls expose accessible names.
**Acceptance:**
- [ ] Keyboard-only navigation can reach and operate every trial CTA; focus is trapped in the interstitial.
- [ ] Hebrew RTL layout renders correctly for banner, card, and interstitial.
- [ ] With reduced-motion enabled, no non-essential animation plays.
- [ ] No hardcoded color/spacing/radius literals remain in the trial components.
