# Integration Hub — Implementation Plan

**Spec:** docs/specs/2026-05-31-integration-hub.md  ·  **Slug:** integration-hub  ·  **Wave:** 11
**Depends on:** calendar-module, contractor-payouts, custom-smtp-email-whitelabel, foundation-auth-rbac, invoices-adapters, payment-gateway-adapters, settings-module

## Goal
Deliver the unified `/settings/integrations` discovery-and-status hub: an "app-store" layout where authenticated tenant users browse every available integration grouped by use-case category, see live connection status for the ones backed by stored credentials, and deep-link out to each integration's own setup/management surface. This spec is **read-only**: it aggregates connection status from existing upstream tables and renders a catalog. It does NOT create credentials, run OAuth, or mutate any integration — every `[Connect]` / `[Configure →]` / `[Manage →]` button is a navigation deep-link or a redirect into another spec's flow.

## Architecture
The hub is one backend aggregator route plus one frontend page in the tenant app.

**Route ownership boundary (critical):** `settings-module` (wave 9) lists the integration mutation endpoints (`POST :adapterId/connect`, `POST :adapterId/sync`, `DELETE :adapterId`) and a per-adapter detail route — those are owned by `settings-module` and each integration's own spec, and are NOT built here. This plan owns ONLY the read-only list aggregator `GET /api/settings/integrations` (the spec's API section is authoritative for that endpoint's response shape) and the hub page that consumes it. No connect/sync/disconnect logic lives in this plan.

**No new tables.** Status is *derived* by querying upstream tables already defined by dependencies:
- `calendar_connections` (calendar-module) → Google Calendar / Outlook status (`provider`, `sync_enabled`, `last_synced_at`, `token_expires_at`).
- `scheduling_connections` (calendar-module) → Calendly / Acuity / moCal status (`provider`, `created_at`).
- `adapter_credentials` (invoices-adapters) → accounting/invoicing adapters status (`adapter_id`, `updated_at`); most-recent `integration_sync_logs` row supplies `last_sync_at` / `error_message`.
- `payment_gateway_configs` (payment-gateway-adapters) → Payplus / Cardcom / Stripe status (`gateway`, `active`).
- `tenant_email_config` (custom-smtp-email-whitelabel) → Custom SMTP / Resend DKIM status (`domain_verified`, `smtp_enabled`).

Integrations listed in the catalog but with no derivation source (Telegram, WhatsApp, Zapier, Make, Webhooks, API Keys) are catalog/link-only entries defaulting to `not_connected`. `contractor-payouts` is a build-order dependency only; the spec references no contractor-payouts table, so no integration is wired for it.

**Backend emits 3 status values** (`connected | error | not_connected`) plus a `details` object. **Frontend derives 6 card states** (not_connected, connecting, connected, connected-with-warning, error, tier-gated) from `status` + `details` + the tier check against the current tenant's `TenantTier`.

**Permission filtering (security cross-cutting):** the route requires an authenticated session (`authMiddleware`). Admins see all integrations; non-admin members see only non-billing integrations (Payment Collection category is hidden for members). This filter is applied server-side in the aggregator before returning.

The hub plugs into `settings-module`'s sidebar navigation builder, which already registers `/settings/integrations` (spec 25 canonical route table). It consumes design-system primitives (`Card`, `Badge`, `Input`, `Spinner`, `Skeleton`, `EmptyState`, `Alert`) and the tier gate hook `useTierGate` / `meetsMinimumTier`.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers). New route module `routes/settings/integrations.ts`, mounted under the existing settings router. Drizzle queries through `tenantQuery` (tenant-scoped) against the upstream tables; never raw Drizzle from the route handler (uses a query helper module, honoring `no-raw-drizzle-from-routes`).
- **App:** `apps/zync-app` (Vite + React on Workers). New page `pages/settings/integrations/IntegrationsHubPage.tsx`, a TanStack Query hook `useIntegrations`, and a static catalog manifest module `integrations/catalog.ts`.
- **Shared types:** `@zync/types` gains `IntegrationStatus`, `IntegrationCategory`, `IntegrationCatalogEntry`, `IntegrationListItem`.
- **DB bindings:** Neon Postgres via Hyperdrive (`DB`), accessed with Drizzle. No migrations (read-only).
- **Auth:** `authMiddleware`, `requirePermission('settings:read')`, session `UserRole` for the admin-vs-member filter.
- **Cross-cutting:** RTL/Hebrew via `useDirection`; `prefers-reduced-motion` respected on the "Connecting" spinner; ARIA roles on cards and the status alert; `requireModuleEnabled` not needed (settings is always-on).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a | 1 (types), 2 (catalog manifest) | `packages/types/src/integrations.ts`, `apps/zync-app/src/features/settings/integrations/catalog.ts` | Yes (independent) |
| 11.b | 3 (status query helper), 4 (aggregator route) | `apps/zync-api/src/services/integrations/status.ts`, `apps/zync-api/src/routes/settings/integrations.ts` | 4 blocked by 1,3 |
| 11.c | 5 (data hook), 6 (hub page), 7 (status bar + states) | `apps/zync-app/src/features/settings/integrations/` | 6,7 blocked by 5; parallel with each other after 5 |
| 11.d | 8 (a11y + RTL + reduced-motion polish) | hub page + IntegrationCard | Blocked by 6,7 |

## Tasks

### Task 1: Shared integration types in `@zync/types`
**Blocks:** 2, 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/integrations.ts` (create file), `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define the closed enums and DTO interfaces used by both API and app.
- [ ] Re-export from the package barrel so consumers import from `@zync/types`.
**Schema / Interfaces:**
```typescript
export type IntegrationStatus = 'connected' | 'error' | 'not_connected';

export type IntegrationCategory =
  | 'accounting'        // Accounting & Invoicing
  | 'calendar'
  | 'scheduling'
  | 'payment'           // Payment Collection (billing-sensitive: hidden from non-admin members)
  | 'email'
  | 'messaging'
  | 'automation'
  | 'custom';

export type IntegrationTierRequired = 'all' | 'business' | 'enterprise';

/** Static catalog entry — known at build time, no DB. */
export interface IntegrationCatalogEntry {
  id: string;                       // e.g. 'google-calendar', 'icount', 'payplus'
  name: string;                     // display name
  category: IntegrationCategory;
  description: string;              // one-line subtitle shown on the card
  tier_required: IntegrationTierRequired;
  manage_url: string;              // deep-link target route
  /** how [Connect] behaves: 'oauth' | 'credential' | 'apikey' | 'config' | 'automation' */
  connect_kind: 'oauth' | 'credential' | 'apikey' | 'config' | 'automation';
  /** which upstream table derives live status, or null for link-only entries */
  status_source: 'calendar_connections' | 'scheduling_connections' | 'adapter_credentials'
               | 'payment_gateway_configs' | 'tenant_email_config' | null;
  /** the provider/key value matched within the status_source table (e.g. 'google', 'payplus') */
  status_key?: string;
  /** true => only admins may see this card (billing-sensitive) */
  admin_only: boolean;
}

/** Live API response item (catalog entry + derived status). */
export interface IntegrationListItem {
  id: string;
  name: string;
  category: IntegrationCategory;
  description: string;
  status: IntegrationStatus;
  tier_required: IntegrationTierRequired;
  manage_url: string;
  connect_kind: IntegrationCatalogEntry['connect_kind'];
  details: {
    last_sync_at?: string;          // ISO-8601
    error_message?: string;
    token_expiring?: boolean;       // amber warning hint for the UI
  };
}
```
**Acceptance:**
- [ ] `IntegrationStatus`, `IntegrationCategory`, `IntegrationTierRequired`, `IntegrationCatalogEntry`, `IntegrationListItem` are exported from `@zync/types`.
- [ ] No build-time type errors in dependent packages.

### Task 2: Static integration catalog manifest
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/settings/integrations/catalog.ts`
**Steps:**
- [ ] Transcribe every integration from the spec's Page Layout + `[Manage →]` deep-links table + `[Connect]` flow into a single `INTEGRATION_CATALOG: IntegrationCatalogEntry[]` array, grouped logically by category.
- [ ] Set `status_source` / `status_key` only for entries with a real upstream table; leave `null` for link-only entries.
- [ ] Set `admin_only: true` for the Payment Collection category (billing-sensitive).
- [ ] Export `CATEGORY_ORDER` and `CATEGORY_LABELS` for deterministic, use-case-ordered rendering (spec: category grouping, not alphabetical).
**Schema / Interfaces:**
```typescript
import type { IntegrationCatalogEntry, IntegrationCategory } from '@zync/types';

export const CATEGORY_ORDER: IntegrationCategory[] = [
  'accounting', 'calendar', 'scheduling', 'payment',
  'email', 'messaging', 'automation', 'custom',
];

export const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
  accounting: 'Accounting & Invoicing',
  calendar:   'Calendar',
  scheduling: 'Scheduling',
  payment:    'Payment Collection',
  email:      'Email',
  messaging:  'Messaging',
  automation: 'Automation',
  custom:     'Custom',
};

export const INTEGRATION_CATALOG: IntegrationCatalogEntry[] = [
  // Accounting & Invoicing (status from adapter_credentials.adapter_id)
  { id: 'morning',   name: 'Morning',   category: 'accounting', description: 'Invoice automation and accounting', tier_required: 'business', manage_url: '/settings/integrations/invoicing', connect_kind: 'credential', status_source: 'adapter_credentials', status_key: 'morning',  admin_only: false },
  { id: 'icount',    name: 'iCount',    category: 'accounting', description: 'Invoice automation and accounting', tier_required: 'business', manage_url: '/settings/integrations/invoicing', connect_kind: 'credential', status_source: 'adapter_credentials', status_key: 'icount',   admin_only: false },
  { id: 'rivhit',    name: 'Rivhit',    category: 'accounting', description: 'Invoice automation and accounting', tier_required: 'business', manage_url: '/settings/integrations/invoicing', connect_kind: 'credential', status_source: 'adapter_credentials', status_key: 'rivhit',   admin_only: false },
  { id: 'invoice4u', name: 'Invoice4u', category: 'accounting', description: 'Invoice automation and accounting', tier_required: 'business', manage_url: '/settings/integrations/invoicing', connect_kind: 'credential', status_source: 'adapter_credentials', status_key: 'invoice4u', admin_only: false },
  { id: 'easycount', name: 'Easycount', category: 'accounting', description: 'Invoice automation and accounting', tier_required: 'business', manage_url: '/settings/integrations/invoicing', connect_kind: 'credential', status_source: 'adapter_credentials', status_key: 'easycount', admin_only: false },
  { id: 'accounting-export', name: 'Accountant Export', category: 'accounting', description: 'Hashavshevet movement file / Form 6111', tier_required: 'business', manage_url: '/settings/integrations/accounting', connect_kind: 'config', status_source: null, admin_only: false },

  // Calendar (status from calendar_connections.provider)
  { id: 'google-calendar', name: 'Google Calendar', category: 'calendar', description: '2-way calendar sync for meetings and scheduling', tier_required: 'business', manage_url: '/settings/integrations/calendar', connect_kind: 'oauth', status_source: 'calendar_connections', status_key: 'google',  admin_only: false },
  { id: 'outlook',         name: 'Outlook / Office 365', category: 'calendar', description: '2-way calendar sync for meetings and scheduling', tier_required: 'business', manage_url: '/settings/integrations/calendar', connect_kind: 'oauth', status_source: 'calendar_connections', status_key: 'outlook', admin_only: false },

  // Scheduling (status from scheduling_connections.provider)
  { id: 'calendly', name: 'Calendly', category: 'scheduling', description: 'Booking links and meeting scheduling', tier_required: 'business', manage_url: '/settings/integrations/calendar', connect_kind: 'apikey', status_source: 'scheduling_connections', status_key: 'calendly', admin_only: false },
  { id: 'acuity',   name: 'Acuity',   category: 'scheduling', description: 'Booking links and meeting scheduling', tier_required: 'business', manage_url: '/settings/integrations/calendar', connect_kind: 'apikey', status_source: 'scheduling_connections', status_key: 'acuity',   admin_only: false },
  { id: 'mocal',    name: 'moCal',    category: 'scheduling', description: 'Booking links and meeting scheduling', tier_required: 'business', manage_url: '/settings/integrations/calendar', connect_kind: 'apikey', status_source: 'scheduling_connections', status_key: 'mocal',    admin_only: false },

  // Payment Collection (status from payment_gateway_configs.gateway) — admin_only
  { id: 'payplus', name: 'Payplus', category: 'payment', description: 'Customer payment collection', tier_required: 'all', manage_url: '/settings/integrations/payments', connect_kind: 'credential', status_source: 'payment_gateway_configs', status_key: 'payplus', admin_only: true },
  { id: 'cardcom', name: 'Cardcom', category: 'payment', description: 'Customer payment collection', tier_required: 'all', manage_url: '/settings/integrations/payments', connect_kind: 'credential', status_source: 'payment_gateway_configs', status_key: 'cardcom', admin_only: true },
  { id: 'stripe',  name: 'Stripe',  category: 'payment', description: 'Customer payment collection', tier_required: 'all', manage_url: '/settings/integrations/payments', connect_kind: 'credential', status_source: 'payment_gateway_configs', status_key: 'stripe',  admin_only: true },

  // Email (status from tenant_email_config)
  { id: 'custom-smtp',  name: 'Custom SMTP',  category: 'email', description: 'Send through your own SMTP relay', tier_required: 'enterprise', manage_url: '/settings/integrations/smtp', connect_kind: 'config', status_source: 'tenant_email_config', status_key: 'smtp', admin_only: false },
  { id: 'resend-dkim',  name: 'Custom From (Resend DKIM)', category: 'email', description: 'Send from your verified domain', tier_required: 'business', manage_url: '/settings/integrations/smtp', connect_kind: 'config', status_source: 'tenant_email_config', status_key: 'dkim', admin_only: false },

  // Messaging (link-only)
  { id: 'telegram',  name: 'Telegram Bot',      category: 'messaging', description: 'Group assistant and notifications', tier_required: 'business',   manage_url: '/settings/integrations/telegram', connect_kind: 'config', status_source: null, admin_only: false },
  { id: 'whatsapp',  name: 'WhatsApp Business', category: 'messaging', description: 'Customer messaging channel',         tier_required: 'enterprise', manage_url: '/settings/integrations/telegram', connect_kind: 'config', status_source: null, admin_only: false },

  // Automation (link-only, OAuth consent)
  { id: 'zapier', name: 'Zapier', category: 'automation', description: 'Connect Zync to 6000+ apps', tier_required: 'business', manage_url: '/settings/integrations/zapier', connect_kind: 'automation', status_source: null, admin_only: false },
  { id: 'make',   name: 'Make',   category: 'automation', description: 'Visual automation workflows', tier_required: 'business', manage_url: '/settings/integrations/make',   connect_kind: 'automation', status_source: null, admin_only: false },

  // Custom (link-only)
  { id: 'webhooks', name: 'Webhooks', category: 'custom', description: 'Outbound event delivery to your endpoints', tier_required: 'enterprise', manage_url: '/settings/integrations/webhooks', connect_kind: 'config', status_source: null, admin_only: false },
  { id: 'api-keys', name: 'API Keys', category: 'custom', description: 'Programmatic access tokens',                 tier_required: 'all',        manage_url: '/settings/api-keys',              connect_kind: 'config', status_source: null, admin_only: false },
];
```
**Acceptance:**
- [ ] Every integration named in the spec's Page Layout and deep-link table appears exactly once.
- [ ] All 8 categories are represented; `CATEGORY_ORDER` matches the spec's use-case ordering.
- [ ] Payment Collection entries are `admin_only: true`; link-only entries have `status_source: null`.

### Task 3: Status-derivation query helper
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/services/integrations/status.ts`
**Steps:**
- [ ] Implement `deriveIntegrationStatuses(db, tenantId)` returning a `Map<string, { status: IntegrationStatus; details: IntegrationListItem['details'] }>` keyed by catalog `id`.
- [ ] Query each upstream table tenant-scoped (`tenantQuery`) in parallel: `calendar_connections`, `scheduling_connections`, `adapter_credentials` (joined to latest `integration_sync_logs` for `last_sync_at`/`error_message`), `payment_gateway_configs`, `tenant_email_config`.
- [ ] Map rows to statuses per the rules below; any catalog id without a matching row defaults to `not_connected`.
- [ ] Use the helper module (not inline route SQL) to satisfy `no-raw-drizzle-from-routes`.
**Schema / Interfaces:**
```typescript
import type { IntegrationStatus, IntegrationListItem } from '@zync/types';
import type { Db } from '@zync/db';

export interface DerivedStatus {
  status: IntegrationStatus;
  details: IntegrationListItem['details'];
}

export async function deriveIntegrationStatuses(
  db: Db,
  tenantId: string,
): Promise<Map<string, DerivedStatus>>;

// Derivation rules (status_source → status):
//   calendar_connections row where provider = status_key:
//     token_expires_at < now()              → 'error'  (token expired)
//     token_expires_at < now()+7d           → 'connected' + details.token_expiring=true
//     sync_enabled=false                     → 'connected' (no warning)
//     last_synced_at present                 → 'connected' + details.last_sync_at
//     else                                   → 'connected'
//   scheduling_connections row where provider = status_key → 'connected'
//   adapter_credentials row where adapter_id = status_key  → 'connected';
//     latest integration_sync_logs(status='error') for that adapter_id
//                                            → 'error'   + details.error_message
//     latest integration_sync_logs(status='success')
//                                            → details.last_sync_at = log.created_at
//   payment_gateway_configs row where gateway = status_key:
//     active=true                            → 'connected'
//     active=false                           → 'not_connected'
//   tenant_email_config (single row per tenant):
//     status_key='smtp'  → smtp_enabled ? 'connected' : 'not_connected'
//     status_key='dkim'  → domain_verified ? 'connected'
//                          : (from_email && !domain_verified) ? 'error' : 'not_connected'
//   status_source=null                       → 'not_connected' (link-only)
```
**Acceptance:**
- [ ] All five source tables are queried tenant-scoped; a tenant with no rows yields all `not_connected`.
- [ ] Expired calendar token yields `status='error'`; near-expiry yields `details.token_expiring=true`.
- [ ] An adapter with a latest error sync log yields `status='error'` with `error_message`.

### Task 4: `GET /api/settings/integrations` aggregator route
**Blocks:** 5  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/settings/integrations.ts`
- Modify: `apps/zync-api/src/routes/settings/index.ts` (mount the route)
**Steps:**
- [ ] Apply `authMiddleware` then `requirePermission('settings:read')`.
- [ ] Resolve the tenant id and session role from the session payload.
- [ ] Call `deriveIntegrationStatuses(db, tenantId)`; merge each `INTEGRATION_CATALOG` entry with its derived status (default `not_connected`).
- [ ] Apply the admin/member filter: if the session role is not an admin/owner role, drop entries where `admin_only === true` (hides the Payment Collection category from members) — security cross-cutting requirement.
- [ ] Return the merged, filtered `IntegrationListItem[]` as JSON. The frontend computes tier-gating and the 6 visual states; the backend emits only the 3 status values + `details`.
- [ ] This route is GET-only; do NOT add connect/sync/disconnect handlers here (owned by settings-module + per-integration specs).
**Schema / Interfaces:**
```typescript
// GET /api/settings/integrations
// Auth: authenticated; admins see all, members see only admin_only===false entries.
// Response: IntegrationListItem[]
//   [{ id, name, category, description, status, tier_required, manage_url, connect_kind, details }]
```
**Acceptance:**
- [ ] Unauthenticated request → 401.
- [ ] Admin response includes Payment Collection entries; non-admin member response excludes them.
- [ ] Response items conform to `IntegrationListItem`; statuses are only `connected|error|not_connected`.
- [ ] No mutation endpoints are registered by this module.

### Task 5: `useIntegrations` data hook
**Blocks:** 6, 7  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/integrations/useIntegrations.ts`
**Steps:**
- [ ] TanStack Query hook fetching `GET /api/settings/integrations`, typed as `IntegrationListItem[]`.
- [ ] Expose loading / error states for the page (`Skeleton` while loading, `ErrorState` on failure).
- [ ] Provide a `useTierGate`-backed derived helper that, given an item, returns one of the 6 card states: `not_connected | connecting | connected | warning | error | tier_gated` — `tier_gated` when `!meetsMinimumTier(currentTier, item.tier_required)`; `warning` when `status==='connected' && details.token_expiring`.
**Schema / Interfaces:**
```typescript
export type IntegrationCardState =
  | 'not_connected' | 'connecting' | 'connected' | 'warning' | 'error' | 'tier_gated';

export function useIntegrations(): {
  integrations: IntegrationListItem[];
  isLoading: boolean;
  error: unknown;
};

export function deriveCardState(
  item: IntegrationListItem,
  currentTier: TenantTier,
): IntegrationCardState;
```
**Acceptance:**
- [ ] Hook returns typed `IntegrationListItem[]`.
- [ ] `deriveCardState` returns `tier_gated` when current tier is below `tier_required`, and `warning` on `token_expiring`.

### Task 6: Integrations hub page (Connected + Available sections)
**Blocks:** 8  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/settings/integrations/IntegrationsHubPage.tsx`
- Create: `apps/zync-app/src/features/settings/integrations/IntegrationCard.tsx`
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/integrations` → `IntegrationsHubPage`)
**Steps:**
- [ ] Render a search `Input` ("Search integrations…") that filters catalog items by name/description client-side.
- [ ] Render a **Connected (N)** section first: all items with `status==='connected'` (any state derived as `connected`/`warning`/`error` that has a backing connection), each as an `IntegrationCard` showing a `[Manage →]` link to `item.manage_url`.
- [ ] Render an **Available integrations** section grouped by `CATEGORY_ORDER` using `CATEGORY_LABELS` headers; within a group, list integration names and the group's tier badge when applicable.
- [ ] Each available card's primary action depends on `connect_kind`: `oauth`→`[Connect]`, `credential`/`apikey`→`[Connect]`, `config`→`[Configure →]`, `automation`→`[Connect]`; all are navigation deep-links to `item.manage_url` (no inline setup here).
- [ ] Tier-gated cards (`deriveCardState==='tier_gated'`) render with a lock icon and route the action through `useUpgradeModal`/`useTierGate` instead of `manage_url`.
- [ ] Loading → `Skeleton` grid; empty filter result → `EmptyState`.
**Acceptance:**
- [ ] `/settings/integrations` renders Connected then categorized Available sections.
- [ ] Search filters cards live; clearing restores the full catalog.
- [ ] `[Manage →]` navigates to the correct `manage_url`; tier-gated cards open the upgrade path, not the manage route.

### Task 7: Status summary bar + card state visuals
**Blocks:** 8  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/settings/integrations/IntegrationsHubPage.tsx`
- Modify: `apps/zync-app/src/features/settings/integrations/IntegrationCard.tsx`
**Steps:**
- [ ] Compute attention count = items whose `deriveCardState` is `error` or `warning`; if >0, render an `Alert` status bar at the top: e.g. "⚠ N integrations need attention — {name} {reason} [Reconnect]" linking to that item's `manage_url`.
- [ ] Map each of the 6 `IntegrationCardState` values to a `Badge`/icon: `not_connected` grey circle, `connecting` `Spinner`, `connected` green dot, `warning` amber dot, `error` red dot, `tier_gated` lock icon — using design-system tokens only (`no-hardcoded-colors`).
- [ ] Show `details.last_sync_at` ("Last sync: 2h ago") and `error_message` on the relevant cards.
**Schema / Interfaces:** (uses `IntegrationCardState` from Task 5; no new types)
**Acceptance:**
- [ ] Status bar appears only when ≥1 integration is in `error`/`warning`, with correct count and per-item reason.
- [ ] All six card states render distinct, token-based visuals; no hardcoded color literals.

### Task 8: Accessibility, RTL, and reduced-motion polish
**Blocks:** —  ·  **Blocked by:** 6, 7
**Files:**
- Modify: `apps/zync-app/src/features/settings/integrations/IntegrationsHubPage.tsx`
- Modify: `apps/zync-app/src/features/settings/integrations/IntegrationCard.tsx`
**Steps:**
- [ ] Cards are a list with `role="list"`/`role="listitem"`; each status icon has an `aria-label` describing the state (e.g. "Connected", "Needs attention"). Status bar uses `role="alert"`.
- [ ] Category headers are real headings (`h2`/`h3`) so screen readers can navigate; search input has an associated visible/`aria` label.
- [ ] Layout uses logical properties / `useDirection` so the hub mirrors correctly under Hebrew RTL; the search field and `[Manage →]` chevron flip direction.
- [ ] The `connecting` `Spinner` and any card hover transitions respect `prefers-reduced-motion` (no spin/animation when reduced).
**Acceptance:**
- [ ] Keyboard tab order traverses search → connected cards → available category groups in order.
- [ ] Page renders correctly mirrored in RTL with no clipped controls.
- [ ] With `prefers-reduced-motion: reduce`, the connecting spinner does not animate.
