diff --git a/.claude/PRPs/reviews/pr-1-review.md b/.claude/PRPs/reviews/pr-1-review.md new file mode 100644 index 0000000..74bef80 --- /dev/null +++ b/.claude/PRPs/reviews/pr-1-review.md @@ -0,0 +1,360 @@ +# PR #1 review — `chore(init): scaffold ChatbotX mobile app` + +**Decision:** COMMENT (author can't self-approve their own PR). Full fix pass applied on the same +branch, left **unstaged** per review-mode convention — see commit message suggestion at the bottom. + +**Scope reviewed:** 259 files, +57k lines. Initial Expo SDK 57 / expo-router scaffold: auth, +workspace picker, 3 tabs (Conversations/Contacts/Settings), chat with attachments, PartyKit +realtime, push notifications, 20-locale i18n (RTL for ar/he), typed REST client from OpenAPI spec. + +**Overall assessment:** high-quality scaffold — zero `any`, SecureStore for tokens, FlashList v2 +done right, full dark-mode token parity, RTL-aware primitives, 20-locale key parity. Findings +clustered in the optimistic-send lifecycle, store hydration, config/tooling gaps, and template +leftovers — all addressed below. + +--- + +## Findings by severity + +### CRITICAL + +- **`src/features/chat/api/use-send-message.ts:161,199` (A1)** — `mutationFn` and `onMutate` each + called `generateClientId()` independently, so the optimistic bubble and the outgoing request body + carried _different_ clientIds — a duplicate bubble until refetch, an orphaned upload-progress + entry, and the retry-scroll signal never firing. **Fix:** normalize `clientId` once in a + `mutate`/`mutateAsync` wrapper before React Query hands params to `onMutate`/`mutationFn`. Added a + regression test asserting request-body clientId === cached optimistic row's clientId. + +- **`src/stores/use-workspace-store.ts` + `src/app/_layout.tsx` (A2)** — no hydration gate on the + workspace store meant a cold start could read `currentWorkspaceId === null` before the persisted + value loaded, bouncing an already-workspace-selected signed-in user to the picker. **Fix:** added + `waitForWorkspaceHydration()` (mirrors `waitForSettingsHydration`), awaited in the auth-bootstrap + effect before splash hides. + +### HIGH + +- **`src/app/_layout.tsx` (A3)** — `preventAutoHideAsync()` had no `.catch`; `useFonts` didn't + destructure `fontError`, so a font-load failure (e.g. offline on first launch) held the splash + screen forever; `useAuthBootstrap` had no try/catch around `getSession`, so a network failure + during session validation looked identical to an invalid token (no distinction between "can't + verify" and "verified invalid"). **Fix:** added `.catch`, `fontsLoaded || fontError` gate, and a + try/catch that treats a validation _failure_ as signed-out-for-this-launch without clearing + SecureStore (so the next successful bootstrap can still sign back in with the same token). Also + added `SplashScreen.setOptions({ fade: true, duration: 300 })` and a root `ErrorBoundary` export + (expo-router convention) so an uncaught render error doesn't white-screen the app. + +- **`src/app/(app)/_layout.tsx:12` (A4)** — `status === 'pending'` (bootstrap not yet resolved) fell + into the `!== 'signed-in'` branch and redirected to sign-in, which could bounce a + still-being-verified signed-in user mid-bootstrap. **Fix:** render `null` while pending instead. + +- **`src/features/chat/api/use-message-actions.ts` (A5)** — `useDeleteMessage` had no rollback on + error (a failed delete left the row permanently marked deleted); `useSetMessageAttributes` had no + cache invalidation at all (like/hide never reflected without a manual refetch); no mutation here + or in `use-send-message.ts`/`use-conversation-actions.ts` cancelled in-flight queries before + patching, so a refetch resolving mid-mutation could clobber the optimistic patch. **Fix:** added + snapshot+rollback to delete, `onSettled` invalidation to attributes and edit, and + `await queryClient.cancelQueries(...)` at the top of every optimistic `onMutate` across all three + files (a shared `cancelConversationQueries` helper in `use-conversation-actions.ts` since those + patches span every cached filter combination). Also fixed a latent bug found while doing this: + `useArchiveConversations`/`useUnarchiveConversations` built their patch object + (`{ archivedAt: new Date().toISOString() }`) once at hook-definition time rather than per + mutation, so every archive used a stale timestamp from whenever the component last rendered — + moved to a `buildPatch()` factory invoked inside `onMutate`. Added `use-message-actions.test.tsx` + (didn't exist before) covering delete rollback and attributes invalidation. + +- **`src/features/permissions/use-permissions.ts:60-71` (A6)** — `useWorkspaceMembersList` had no + `enabled` guard, so a `null`/empty workspaceId fired `GET /workspaces//members`, which could + trigger the global 401→sign-out handler. **Fix:** `enabled: workspaceId.length > 0`. Also deleted + a byte-for-byte duplicate `useWorkspaceMembers` in `assignment-sheet.tsx` and repointed it at the + shared hook (same query key, so no extra request in practice — this was pure duplication). + +- **`src/lib/query-client.ts:21-31` (A7)** — the global `retry: 1` retried every error including + 4xx (bad input, unauthorized, not found — retrying identically can't change those outcomes), and + the 401→sign-out handler fired unconditionally, without checking whether the failing request had + actually carried a token (so a 401 raced against bootstrap, or from a request that never had a + token, could force-sign-out a user who was never actually rejected). **Fix:** `retry` is now a + predicate that skips 4xx via `ApiError.body.status`; the 401 handler now checks `getAuthToken()` + before clearing anything. + +- **`src/features/chat/api/send-message-multipart.ts:49,119` (A8)** — the hand-rolled XHR upload + path threw its own `SendMessageError` class, which `normalizeApiError` didn't recognize at all — + a 402 quota rejection on an attachment send silently fell through as "unknown error" instead of + showing the quota banner. **Fix:** replaced `SendMessageError` with `ApiError` built from the + parsed XHR response body (same `{ code, status, message, data }` shape the typed `apiClient` path + produces), so `normalizeApiError` classifies it identically regardless of send path. + +- **`src/features/contacts/pii-mask.ts` (A9)** — `maskPhone` sized its asterisk run off the raw + string length (including formatting characters like `-`/`(`/`)`/spaces) instead of the actual + digit count, so a formatted number like `(555) 123-4567` masked with 4 extra asterisks it didn't + need; `maskEmail` split on the _first_ `@`, which breaks for a (rare but legal) quoted local-part + containing `@`, and didn't guard an empty local-part. **Fix:** `maskPhone` sizes off `digits`; + `maskEmail` splits on `lastIndexOf('@')` and guards `atIndex <= 0`. Updated + `pii-mask.test.ts` with the corrected expectations plus new empty-local-part and + quoted-local-part cases. + +- **`src/app/(app)/profile.tsx` + `settings/index.tsx` (A10)** — duplicate sign-out logic (fetch + token, best-effort revoke, clear token, clear query cache, clear workspace, flip auth store); the + profile-modal copy had **no confirmation and wasn't marked destructive**, so a stray tap + immediately signed the user out. **Fix:** extracted `useSignOut()` (`src/features/auth/use-sign-out.ts`) + with the confirm-then-clear-everything flow from settings/index.tsx; both screens now share it. + Folded in the adjacent Phase D ask while touching profile.tsx: removed the "Plan / —" placeholder + section (no plan/billing API exists yet to back it) and the empty trailing `SectionHeader`. + +### MEDIUM + +- **`src/lib/notification-tap.ts:43-50` (A11)** — `getLastNotificationResponseAsync().then(...)` + had no cancellation guard and no `.catch`; a slow cold-start resolution after the listener's + cleanup ran could still call `handleResponse` against a torn-down state, and a rejected promise + was unhandled. **Fix:** `cancelled` flag + `.catch`. + +- **`src/realtime/use-realtime-handlers.ts:34-42` (A12)** — the debounced + conversations-list-invalidate timers are module-scope (`Map`), so nothing + ever cleared them when the realtime connection tore down — a leaked `setTimeout` per + not-yet-cached conversation, and the source of the Jest "worker failed to exit gracefully" + warning noted in the baseline. **Fix:** exported `clearPendingConversationInvalidates()`, called + alongside `clearAllTypingTimers()` in `realtime-provider.tsx`'s unmount effect. Verified: a full + suite run right after this fix showed **no** exit warning. (See "Known issues" below — a + _different_, apparently pre-existing leak resurfaced later, isolated to + `use-send-message.test.tsx`; not chased further, see notes.) + +- **`src/realtime/use-realtime-handlers.ts:88-94` (A13)** — `messageCreated` for the conversation + the user is actively viewing didn't patch `agentLastReadAt`, so the inbox unread badge could drift + stale while the user was reading the conversation live over the socket. **Fix:** + `applyConversationMessageCreated` now takes `activeConversationId` and bumps `agentLastReadAt` + when the incoming message's conversation matches it. Added tests for both the bump and the + not-active case. + +- **`src/realtime/apply-conversation-events.ts:141-144` (A14)** — hoisted the matched conversation + row to the front of _whichever page it was found on_, not just page 0 — for a row on a later + page, this duplicated it across the client's merged view and corrupted cursor pagination. **Fix:** + only hoist when `pageIndex === 0`; a later-page match is patched in place. Added a multi-page + regression test. + +- **`src/realtime/realtime-provider.tsx:83-91` (A15)** — `resetConnectionStore()` ran in a + post-mount `useEffect`, but `usePartySocket` starts connecting immediately on render — a fast + `onOpen` could fire and flip the store to `'open'` _before_ the effect's reset ran, clobbering it + back to `'connecting'` and leaving the connection banner stuck. **Fix:** reset moved to run + exactly once per mounted instance via `useState`'s lazy initializer (not a ref read/write during + render — the project's `reactCompiler: true` setting disallows that pattern; confirmed by ESLint + during this review, see "Caught during review" below). + +- **`src/realtime/parse-event.ts:65` (A16)** — `messageFailed`'s validator rejected `error === +undefined` (only `null` or a string passed), so an event that _omitted_ the field entirely (valid + per the type, which already allowed optional) failed validation and the whole event was dropped + silently. **Fix:** `isOptionalNullableString`; also widened `RealtimeEventMessageFailed.error` to + `string | null | undefined` in `events.ts` to match. + +- **`app.config.ts:34` (A17)** — `googleServicesFile` was referenced unconditionally, but the file + is gitignored (per-dev/CI secret) — every clean checkout's Android build/prebuild would fail + before the secret is provisioned. **Fix:** conditional on `existsSync`; documented the EAS + file-secret setup in README. + +- **`app.config.ts` + `src/i18n/reconcile-rtl.ts` (A18)** — expo-updates was installed but not + wired up (no `runtimeVersion`/`updates`/plugin entry); the RTL bootstrap called `forceRTL` + _before_ checking `__DEV__`, doing an unnecessary native call it then discarded; and the silent + bootstrap reload path could pop a blocking `Alert` if `!Updates.isEnabled`, from a code path with + nothing rendered yet to explain the alert's context to the user. **Fix:** added + `runtimeVersion: { policy: 'appVersion' }`, `updates.url`, and the `'expo-updates'` plugin; + reordered the `__DEV__` check before `forceRTL`; `reloadApp` now takes a `{ silent }` option, used + by the bootstrap path to fall back to silence instead of alerting. + +- **`eas.json` (A19)** — no per-profile `env`, so a preview/production build had no + `API_BASE_URL`/`WS_URL` baked in — worst case, shipping a build that silently points at + `localhost`. **Fix:** added `env` blocks per profile (dev points at local ports; preview/ + production carry placeholder URLs the team should replace with real values before first use). + +### Dead code / template leftovers (no user-facing behavior change) + +- Deleted `scripts/reset-project.js`, its `reset-project` script, and the `example` line in + `.gitignore` (destructive create-expo-app leftover with zero purpose in a real app). +- Deleted `src/hooks/` (`use-color-scheme.ts` + `.web.ts`) — zero importers anywhere. +- Deleted `useThemedStyles` (`use-theme.ts`) — zero callers; deleted `motionEasings`/`motionSprings` + from `tokens.ts` and the corresponding `durations`/`easings`/`springs`/`pressScale` re-exports from + `theme/motion.ts` (kept only `useReducedMotion`, the only thing anything actually imports from + that module) — confirmed zero consumers of `theme.motion.easings`/`.springs` anywhere in the tree. +- Removed the empty `if (context.skippedOptimistic) {}` block in `use-send-message.ts` (folded into + the existing comment); `MAX_FILE_SIZE_BYTES` is now `export const` at its definition instead of a + bottom re-export statement. +- Deleted `ActionContext` (`use-conversation-actions.ts`) — declared, exported, never imported + anywhere. +- Removed the no-op `['device-token']` query invalidation in `use-device-token.ts` — nothing ever + caches under that key; also added a cancellation flag to the push-token-rotation listener + (previously no guard against firing register/unregister calls after unmount). +- Made `MAX_MULTI_SELECT` module-private in `pick-attachments.ts` (only used within its own file). +- Removed the identity `.map` and no-`await` `async` in `composer.tsx`'s `handleCamera`/ + `handleDocument`. +- **Deps removed** (and `pnpm install` re-run to refresh the lockfile): `react-dom`, + `react-native-web`, `test-renderer` (wrong package name — not `react-test-renderer`; nothing + imported it), `cross-env` (nothing referenced it). Removed the `web` config block + + `favicon.png` reference from `app.config.ts`, and the `pnpm web` script. +- **Dev gallery**: moved `(dev)/gallery.tsx`'s ~380-line body (24 UI-primitive imports) to + `src/dev/gallery-content.tsx`, `require()`d lazily inside the route's `__DEV__` branch — those + imports no longer ship in production bundles, while the route itself still resolves so typed + routes stay valid in every build. +- **Deprecated color aliases**: codemodded all ~28 remaining call sites off + `colors.{background,surface,surfaceElevated,border,text,primary,primaryForeground}` onto the + semantic keys (`bg`/`surface2`/`surface1`/`borderSubtle`/`textPrimary`/`brand`/`onBrand`), deleted + the alias block from `tokens.ts` and its doc comment, updated `tokens.test.ts` to assert the + aliases are now `undefined` instead of asserting they exist. +- Consolidated `flattenMessagePages`/`flattenConversationPages` (each a one-line wrapper around + `flattenPages`) — deleted both, call sites now use `flattenPages` directly. +- Removed stale plan-artifact comments ("Phase N", references to a plan/report) opportunistically + in every file this review otherwise touched; a repo-wide sweep of files _not_ otherwise touched + was out of scope for this pass — grep `Phase [0-9]\|see report\|see plan` in `src/` for the + remainder if a follow-up wants to finish that sweep. +- `isWorkspaceListQuery`-style consolidation (query-keys.ts helper unifying the + `queryKey[2]==='conversations' && queryKey[3]==='list'` predicate scattered across + `use-conversation-actions.ts`/`use-realtime-handlers.ts`/`find-conversation-in-cache.ts`) was + **not done** in this pass — the individual predicates were left as-is since consolidating them + touches several files for a pure readability win with no behavior change; flagged as a good + follow-up, not blocking. +- Removed `queryKeys.ws.contacts.tags`/`.sequences` — zero callers anywhere. + +### UI/UX polish + +- Touch targets: `error-banner.tsx`'s retry action now has `hitSlop={8}` + + `accessibilityLabel`; `segmented-tabs.tsx` segments now have `minHeight: 44`, and its indicator's + `left: 0` → `start: 0` (was breaking in RTL). +- Contrast: light-mode `textTertiary` `#948d7c` (~3.0:1 against `bg`) → `#767063` (~4.51:1, meets + WCAG AA) — dark mode was already compliant (~4.57:1), left unchanged. +- Haptics: `ListItem`'s default flipped from `'light'` on every navigation tap to `false` — + navigating to a screen doesn't need a haptic beyond the transition itself; destructive rows keep + `'medium'`. Chips/toggles/segmented-tabs already correctly used `'selection'`, unchanged. + `camera.tsx` now fires a `'light'` haptic on capture, `'success'`/`'error'` on + settle, and dims the shutter button with an `ActivityIndicator` while capturing (previously no + visual feedback at all beyond `disabled`). +- `+not-found.tsx`: the "go home" button now uses `EmptyState`'s own `action` prop instead of + floating below it as a separate `Button`. +- `sign-in.tsx`: replaced the `Card`-as-divider hack with a proper rule-with-label row (`Divider` + + centered caption); the logo block now fades in (`FadeInUp`, reduced-motion aware). +- `index.tsx`'s `LoadingScreen`: bare `ActivityIndicator` → branded pulsing logo mark (matches the + sign-in screen's icon treatment), reduced-motion aware. +- Loading fidelity: `contacts/index.tsx` → `SkeletonRow.Contact` (was a flat generic block); + `settings/members.tsx` → `SkeletonRow.Conversation` shape (closer match to its avatar+title+badge + rows); `[conversationId]/contact.tsx`'s outer gate skeleton now matches `ContactPanel`'s own + loading shape exactly, instead of a bare single circle. +- **Not done in this pass:** empty-state descriptions/actions for `conversations.empty` / + `contacts.empty` / `workspaces.empty` (needs new i18n keys across 20 locales + an + `scripts/i18n-sync.ts` run — scoped out given the size of this pass already); the Settings screen + profile-row-as-branded-header-card / theme-picker-as-mini-previews / `tabBarBadge`-matching- + `CountPill` redesign asks (larger visual redesign work, not a bug fix); `settings.tsx` push-toggle + distinct-copy-for-denied-vs-failure; `image-viewer.tsx` accessibility label + pinch-zoom. + +### Performance / correctness (Phase C) + +- `[conversationId]/index.tsx:52-53` — `[...messages].reverse()` was recomputed inline every + render, invalidating `message-list.tsx`'s own `messagesById`/`rows` `useMemo`s on every parent + re-render. **Fix:** `flattenPages` result and the reversed array are both memoized now. +- `justSentMessageId` in the same file was derived from `sendMessage.isSuccess` — which stays + `true` for the mutation's entire lifetime until the next `mutate()` call, so it pointed at a + stale clientId indefinitely between sends. **More severe than the plan's phrasing suggested**: + this `useSendMessage(...)` instance is used _only_ for the retry path in this screen — the + composer instantiates its **own separate** `useSendMessage(...)` for normal sends, so + `sendMessage.data` here never reflected a composer send at all; the force-scroll-to-own-message + behavior was effectively broken for every non-retry send. **Fix:** added + `justSentClientIdByConversation` to the shared `useChatStore`, set once in `useSendMessage`'s + `onSuccess` (skipped for flow sends), read by the chat screen regardless of which hook instance + performed the send. +- `handleRetry` only re-sent `text`, silently dropping any attachments or reply context — retrying + a failed attachment/reply send produced a plain text message. **Fix:** rebuilds + `attachments`/`replyTo` from the failed optimistic row (parent `createdAt` recovered from the + already-loaded messages list, since the row itself only carries `parentId`). +- Hoisted per-render color-map/variant-map object literals behind `useMemo` in `text.tsx`, + `button.tsx`, `connection-banner.tsx`, `error-banner.tsx` — each rebuilt a lookup object every + render just to read one entry out of it; `text.tsx`/`button.tsx` in particular render on every + message/list row. +- `toast.tsx:68-94` — the exit-animation's inner `setTimeout(dismissCurrent, ...)` was never + tracked or cleared, only the outer one; if `current` changed mid-exit-animation the inner timer + still fired later against a stale render. **Fix:** both timers tracked, both cleared in cleanup; + also filled in the effect's real dependencies (`reducedMotion`, `motion.durations.*`, + `translateY`, `opacity`) instead of a blanket exhaustive-deps disable — `reducedMotion` in + particular is genuinely not static (OS accessibility toggle). +- `swipeable-row.tsx:119-142` — `trackProgress` mutated a shared value (`progressShared.value`) as + a side effect of a render-phase function (`renderLeftActions`/`renderRightActions`, invoked by + RNGH during render to build the action panels) — exactly the pattern React/reanimated warn + against, even though it "worked." **Fix:** removed `progressShared`/`useAnimatedReaction` + entirely; the reveal haptic now fires from RNGH's own `onSwipeableWillOpen` callback. +- `skeleton.tsx:29-36` now respects `useReducedMotion()` (static opacity instead of a repeating + pulse). +- `screen.tsx` — dropped the redundant inner `flex: 1` `View` (the outer `SafeAreaView` already + provides it; pure passthrough wrapper). +- `dayjs-locales.ts` — was 20 static side-effect imports at module load; now each locale is loaded + lazily inside `setDayjsLocale`. Metro needs a **static, literal** `require()` argument to bundle a + module (a template-literal path can't be resolved at bundle time, unlike webpack's context + modules) — used a per-locale literal-`require()` lookup table rather than one dynamically-built + call. +- `use-conversation-actions.ts:255,266` — `new Date().toISOString()` was called once at + hook-definition time (inside `useArchiveConversations`/`useUnarchiveConversations`'s call to the + shared `useBatchConversationAction`), not per-mutate — every archive optimistically patched with a + timestamp from whenever the component last rendered, not the actual archive time. **Fix:** the + patch is now built by a `buildPatch()` factory invoked inside `onMutate`. +- `use-device-token.ts:78-87` — added a cancellation flag to the push-token-rotation listener (see + "Dead code" section above for the paired no-op-invalidation removal in the same file). +- Replaced all 7 `router.push/replace(... as never)` sites with the typed object form + (`{ pathname, params }`) where the target is a route this app's typed-routes manifest knows + statically; the 2 sites where the target is a genuinely arbitrary runtime string (captured deep + links in `sign-in.tsx`/`social-buttons.tsx`) use expo-router's own `Href` type instead of `never` + — a more accurate cast for "any valid path string," not a workaround. +- Added `export function ErrorBoundary` in `src/app/_layout.tsx` (see A3). +- `pending-deep-link.ts` now accepts both a bare `conversations/:id`/`contacts/:id` deep link and + one that already embeds the `(app)` route-group prefix — previously only the bare form matched. + Added `pending-deep-link.test.ts` (didn't exist before) covering both shapes plus the + unrecognized-path and consume-once cases. + +### Tooling / CI (Phase E) + +- `.github/workflows/ci.yml`: added `pnpm format:check` and `npx expo-doctor` steps; bumped + `node-version` to 22. +- `package.json`: added `"packageManager": "pnpm@11.15.1"` so `pnpm/action-setup@v4` in CI resolves + a pinned version automatically instead of drifting to whatever's latest. +- Fixed the `import/first` warnings in `src/api/client.test.ts` (the two `jest.mock()` calls were + above the imports they mock — Jest hoists `jest.mock` regardless of source position, so this was + purely a lint-order fix, not a behavior change). +- Ran `pnpm format` across the repo (also reformatted two `.claude/commands/*.md` files that were + already out of Prettier's style — incidental, not hand-edited). +- **Not done** (blocked by this repo's own config-protection hook, which exists specifically to + stop agents from silently weakening lint/format rules): `no-console` rule in `eslint.config.js`, + `endOfLine: 'lf'` in `.prettierrc.json`. Both are legitimate, plan-requested additive changes, not + a weakening — but the hook can't distinguish that, and bypassing a guard rail the user + deliberately installed isn't this review's call to make. **Action needed from a human:** apply + these two one-line config changes directly, or explicitly disable the hook for this edit. +- `app.config.ts`: `newArchEnabled`/`edgeToEdgeEnabled` from the original plan **don't exist** as + config properties in Expo SDK 57 — both New Architecture and edge-to-edge are mandatory/default + with no opt-out knob in this SDK version (confirmed against the versioned SDK 57 docs). Left + explanatory comments in place of the properties rather than setting nonexistent config keys. +- **Not done:** `scripts/i18n-sync.ts` marking backfilled English placeholders — real work (a + `*.todo.json` sidecar or CI count check) that's independent of everything else in this pass; + scoped out. + +--- + +## Verification + +| Check | Result | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm typecheck` | ✅ clean | +| `pnpm lint` | ✅ 0 errors (8 pre-existing warnings in `jest.setup.ts`/`scripts/i18n-sync.ts`/`.expo/types`, none in touched files) — **1 real error caught and fixed during this review**: `realtime-provider.tsx`'s ref-read-during-render (see A15) violated `reactCompiler: true`'s stricter ref-access rule; fixed via `useState`'s lazy initializer instead of a ref | +| `pnpm format:check` | ✅ clean | +| `pnpm test` | ✅ 41 suites / 293 tests passing (up from 39/279 at baseline — new tests for A1's clientId-parity, A5's delete-rollback/attributes-invalidate, A9's pii-mask cases, A14's page-0-only hoist, pending-deep-link's path matching) | +| `npx expo-doctor` | 2 pre-existing, unrelated failures: 14 packages with patch-version drift from the exact SDK-pinned versions, and a duplicate `expo-file-system` resolution — neither touched by this pass, not silently bumped (native-build-affecting, out of scope for a review-fix pass) | +| `npx expo config --type public` | shows `updates`/`runtimeVersion`, no `web` block | +| `git status` | only unstaged modifications, no staged changes, no unexpected untracked files beyond the 4 new files this pass added | + +**Known issue not resolved:** the Jest "worker failed to exit gracefully" warning is back after a +clean run right after A12's fix showed it gone. Isolated it to `use-send-message.test.tsx` +specifically (running that file alone, including its _pre-existing, untouched_ tests, reproduces it +with `--forceExit` needed) — this points at something inherent to that test file's setup (likely a +native-module mock or `jest-expo` environment characteristic) rather than a regression from this +pass's changes, but I could not get a definitive stack trace: `--detectOpenHandles` took several +minutes to return in this sandbox (worker-pool disabled, real Node handle timeouts), well past what +was practical to wait out repeatedly. All 293 tests pass correctly regardless — this only affects +the test _process's_ exit, not test correctness. Flagging for a follow-up with a faster local +environment to run `--detectOpenHandles` to completion. + +--- + +## Suggested commit message + +``` +fix(scaffold): address PR #1 review — optimistic-send ids, hydration gate, config, dead code +``` diff --git a/.claude/commands/audit.md b/.claude/commands/audit.md new file mode 100644 index 0000000..b4d9b3c --- /dev/null +++ b/.claude/commands/audit.md @@ -0,0 +1,32 @@ +--- +description: Audit the project's AI infrastructure (agents, skills, commands, context, rules) for gaps, drift, and security issues +allowed-tools: Bash, Read, Grep, Glob, Task +--- + +Run a focused audit of the repo's AI-assist infrastructure and report findings — analysis only, no edits. + +## Scope + +In scope: `.claude/` (agents, commands, settings), `.agents/` (skills, rules), `.github/copilot-instructions.md`, root `SKILL.md`, `AGENTS.md`, `CLAUDE.md`. **Out of scope:** editor-specific `.cursor/**` and Devin agent config. + +## Steps + +1. **Inventory** — list every in-scope file with line count and git-tracked/ignored status (`git ls-files`, `git check-ignore`). Flag untracked load-bearing config and missing referenced paths (e.g. a rules dir referenced by `AGENTS.md` that does not exist on disk). + +2. **Coverage & drift** — check that each tool's context (Claude, Copilot, and any editor-rule mirrors) carries the project invariants from `AGENTS.md`; flag any tool whose context omits them, and any invariant duplicated across files that can drift. + +3. **Skills & commands** — flag skills >400 lines (split candidates), skills/commands that duplicate a `~/.claude/` global, and prompts with no stop condition or with phantom commands. + +4. **Security** — scan `.claude/settings.local.json` for credential literals (report by location, never reproduce the value), wildcard `Bash(* )` grants, `.env`-copy/home-dir/`/etc` read grants, and machine-absolute or wrong-repo paths. + +5. **Report** — findings table `file:line | issue | severity | fix`, severity-ranked. For a deep pass, read the `security-review` skill and dispatch specialist agents (`rag-eval`, `invariant-guard`) when relevant. + +## Arguments + +$ARGUMENTS — optional surface to focus on (e.g. `skills`, `security`, `context`). + +## Guardrails + +- Analysis only — never edit infra files; output findings + recommendations. +- Never reproduce secret values; reference by `file:line` only. +- Cite real `path:line` for every finding; drop anything you cannot evidence. diff --git a/.claude/commands/commit-pr.md b/.claude/commands/commit-pr.md new file mode 100644 index 0000000..9f9db75 --- /dev/null +++ b/.claude/commands/commit-pr.md @@ -0,0 +1,57 @@ +--- +description: Commit staged changes, push branch, and open a PR in one shot +allowed-tools: Bash, Read +--- + +Run `/commit` flow then push and open PR. Follows `.agents/rules/git.md`. + +## Steps + +1. **Commit** — follow `.claude/commands/commit.md` end-to-end (analyze diff, stage specific files, conventional `(): `, HEREDOC, no `--no-verify`, no attribution). Abort if nothing to commit. + +2. **Push** — parallel: + - `git rev-parse --abbrev-ref HEAD` to get branch + - `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null` to check upstream + - If no upstream: `git push -u origin `; else `git push` + - Never force-push. Never push to `main`/`master` directly. + +3. **PR** — parallel: + - `git log main..HEAD --oneline` for commit list + - `git diff main...HEAD --stat` for scope + - `gh pr view --json url 2>/dev/null` to detect existing PR (skip create if present, return URL) + +4. **Draft PR body**: + - Title: same as commit subject (≤ 100 chars). Add `#` if `$ARGUMENTS` contains issue ref. + - Body sections: `## Summary` (1–3 bullets, why), `## Changes` (key files/modules), `## Test plan` (markdown checklist). + +5. **Create PR** against `main` (the project's canonical PR base): + + ```bash + gh pr create --base main --title "" --body "$(cat <<'EOF' + ## Summary + - ... + + ## Changes + - ... + + ## Test plan + - [ ] pnpm lint + - [ ] pnpm --filter <app> check-types + - [ ] manual verification + EOF + )" + ``` + +6. Return PR URL. + +## Arguments + +$ARGUMENTS — optional issue ref / hint (e.g. `#414 flow token`). Used in PR title/body when present. + +## Guardrails + +- No `git add -A`/`.`, no `.env`, no secrets. +- No `--no-verify`, no `--force`/`--force-with-lease` unless user explicitly asks. +- No attribution footers. +- If branch is `main`/`master`: abort and tell user to create feature branch first. +- If `pnpm lint` or typecheck obviously broken in diff, warn before pushing (do not auto-run unless asked). diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..bafa467 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,47 @@ +--- +description: Stage specific files and create a conventional commit following ChatbotX git rules +allowed-tools: Bash, Read +--- + +Create a git commit for the current changes following the ChatbotX conventions in `.agents/rules/git.md`. + +## Steps + +1. Run in parallel: + - `git status` (never `-uall`) + - `git diff` (staged + unstaged) + - `git log -n 10 --oneline` for style reference + +2. Analyze the diff: + - Determine commit `type`: `feat`, `fix`, `refactor`, `docs`, `style`, `test`, `chore`, `ci`, `perf`, `build`, `revert` + - Determine `scope` from touched package/app/feature when obvious (e.g. `builder`, `worker`, `whatsapp`, `database`) + - Draft subject: `<type>(<scope>): <subject>` — ≤ 100 chars, lowercase after `:`, no trailing period + - Add body only when the _why_ is non-obvious + +3. Stage **specific files only** — never `git add -A` or `git add .`. + - Skip `.env*` and any file containing secrets. Warn if user explicitly requests them. + +4. Commit using a HEREDOC so formatting is preserved: + + ```bash + git commit -m "$(cat <<'EOF' + <type>(<scope>): <subject> + + <optional body> + EOF + )" + ``` + - Do **not** pass `--no-verify`. Let `lefthook` commit-msg + pre-commit hooks run. + - If a hook fails, fix the underlying issue and create a **new** commit (do not `--amend`). + +5. Run `git status` after commit to confirm clean state. + +## Arguments + +$ARGUMENTS — optional. Treat as a hint about scope/intent (e.g. `flow token fallback`). Still derive type/scope from the diff. + +## Guardrails + +- No attribution / `Co-Authored-By` footers (disabled globally). +- No pushing in this command — use `/pr` for that. +- If nothing is staged and there are no changes, abort without creating an empty commit. diff --git a/.claude/commands/implement-plan.md b/.claude/commands/implement-plan.md new file mode 100644 index 0000000..47753d3 --- /dev/null +++ b/.claude/commands/implement-plan.md @@ -0,0 +1,157 @@ +--- +description: Implement approved technical plans with execution-first behavior +--- + +# Implement Plan + +You are in IMPLEMENTATION MODE. + +The implementation plan has already been approved. + +Your responsibility is execution, not planning. + +Follow the shared repository workflow in `.agents/skills/implement-plan/SKILL.md`. + +## Core Principles + +- Implement code, do not create plans. +- Follow the implementation plan exactly. +- Respect approved architecture decisions. +- Respect approved API contracts. +- Respect approved database schema. +- Reuse existing code whenever possible. +- Keep changes minimal and focused. +- Avoid speculative improvements. +- Avoid unrelated refactoring. +- Do not expand scope. + +## Getting Started + +When given a plan path: + +1. Read the plan completely. +2. Check for completed items (- [x]). +3. Read the original ticket if it is referenced or provided. +4. Read all files referenced by the plan. +5. Read files completely. +6. Understand the surrounding code before making changes. +7. Create an internal task list. +8. Begin implementation immediately. + +If no plan path is provided, ask for one. + +## Critical Execution Rules + +- DO NOT create a new implementation plan. +- DO NOT rewrite the existing implementation plan. +- Only update task checkboxes/status after implementation and verification. +- DO NOT ask for approval before coding. +- DO NOT explain what you intend to do before making changes. +- DO NOT stop to summarize before implementation. +- DO NOT pause unless blocked by ambiguity or a plan mismatch. + +Your first action should be reading the plan and relevant files. + +Your first response should never be a plan. + +## Implementation Process + +1. Read the implementation plan. +2. Find the highest-priority unfinished task. +3. Implement that task completely. +4. Verify the implementation. +5. Update task checkbox/status if applicable. +6. Stop after the task is complete. + +Only work on ONE task at a time. + +Prefer the smallest independently deliverable unfinished task. + +Do not automatically continue to the next task. + +## Plan Mismatch Handling + +If reality differs from the plan: + +STOP and explain: + +Issue in Phase [N] + +Expected: +[what the plan specifies] + +Found: +[actual implementation reality] + +Why this matters: +[technical impact] + +Recommended path: +[best option] + +Wait for guidance before proceeding. + +## Verification + +After implementation: + +- Run relevant tests. +- Run lint. +- Run typecheck. +- Fix all issues introduced by your changes. +- Ensure the project builds successfully. +- Verify success criteria defined in the plan. + +## Manual Verification + +Only pause for manual verification when: + +- The plan explicitly requires manual testing. +- The implementation cannot be fully validated automatically. +- User interaction is required. + +Otherwise continue implementation. + +Do not mark manual verification items complete until confirmed by the user. + +## Resuming Work + +If checkmarks already exist: + +- Trust completed work unless it blocks the current task, conflicts with current code, or verification fails. +- Resume from the first unfinished item. +- Only revisit completed work if necessary. + +## Output Format + +### Task Implemented + +- What was implemented +- Why it was required + +### Files Changed + +- File path +- Reason for change + +### Validation + +- Tests executed +- Test results +- Lint status +- Typecheck status +- Build status + +### Remaining Work + +- Remaining unfinished tasks from the implementation plan + +## Important + +- Implementation first. +- Code first. +- No planning. +- No redesign. +- No scope expansion. + +Your first code-changing action should be implementation, not replanning. diff --git a/.claude/commands/release-check.md b/.claude/commands/release-check.md new file mode 100644 index 0000000..3826b12 --- /dev/null +++ b/.claude/commands/release-check.md @@ -0,0 +1,41 @@ +--- +description: Run the full pre-release verification gate (lint, types, tests, coverage) and report readiness +allowed-tools: Bash, Read, Grep +--- + +Verify the repo is release-ready by running the gate sequence from the `testing-workflow` skill. CI only builds Docker images, so this gate is your real safety net — run it before tagging a release or opening a release PR. + +## Steps + +1. **Scope** — determine what changed: `git diff main...HEAD --stat` (or the given range). Identify the touched workspaces. + +2. **Lint** — `pnpm lint`. If it fails, stop and report; suggest `pnpm fix` for auto-fixable issues. + +3. **Types** — for each touched workspace: `pnpm --filter <workspace> check-types`. Report the first failure per workspace. + +4. **Tests + coverage** — run the affected packages' Vitest suites. Do **not** set `VITEST_SKIP_COVERAGE_THRESHOLDS`; the 80% threshold must hold (`packages/vitest-config/src/node.ts`). Report any suite below threshold. + +5. **Invariant scan** — dispatch the `invariant-guard` agent on the diff to catch the non-lintable invariants. + +6. **Secret scan** — grep the diff for credential patterns (`PGPASSWORD`, `DATABASE_URL=`, API keys); confirm no `.env`/secret is staged. + +## Output + +A readiness checklist: + +``` +RELEASE CHECK +- lint: PASS / FAIL (<detail>) +- types: PASS / FAIL (<workspace>) +- tests: PASS / FAIL (<suite>) +- coverage: >=80% / BELOW (<package> <n>%) +- invariants: PASS / <n> violations +- secrets: CLEAN / FOUND (<file:line>) +=> READY / NOT READY +``` + +## Guardrails + +- Read-only verification — do not edit code to make a gate pass; report the failure and let the owner fix it. +- Never report READY on a gate you did not actually run; say which gate was skipped and why. +- Never bypass coverage with the skip env var. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..588dcb0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "enabledPlugins": { + "expo@claude-plugins-official": true, + "ui-ux-pro-max@ui-ux-pro-max-skill": true + } +} diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff699c0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Base URL of the ChatbotX builder app (oRPC + REST API + OpenAPI spec host). +# Defaults to the builder app's standard local dev URL when unset. +API_BASE_URL=http://localhost:3123 + +# PartyKit realtime server URL. Defaults to the local `partykit dev` port when unset. +WS_URL=http://localhost:1999 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3a8157 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Format check + run: pnpm format:check + + - name: Test + run: pnpm test + + - name: Expo config/dependency doctor + run: npx expo-doctor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..641dc92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +google-services.json +GoogleService-Info.plist + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f37dbee --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +.expo +src/generated +src/api/generated +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..47174e4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..17becaa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Expo HAS CHANGED + +Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code. + +# Crypto + +Never import Node's `crypto` (`crypto` / `node:crypto`) in app code. React Native runs on +**Hermes**, which has no Node standard library — the import resolves fine at build/type-check +time and even under Jest, then crashes only on a real device. Do not add `crypto-browserify` or +similar Node-crypto shims either. + +- Use `expo-crypto` directly, or the Web-Crypto-shaped `crypto` global installed by + `src/lib/install-crypto-polyfill.ts` (imported for its side effect at the top of + `src/app/_layout.tsx`). +- `expo-crypto` already covers random generation, SHA-2 digests, and AES-GCM — do not add + `react-native-get-random-values`, `expo-standard-web-crypto`, or `react-native-quick-crypto`; + they'd be redundant. +- The installed `crypto` global only provides `randomUUID` and `getRandomValues`. + **`crypto.subtle` is not implemented** — anything needing it (real hashing/HMAC/JWT + verification) must call `expo-crypto`'s `digest`/`digestStringAsync` directly instead. +- `src/features/chat/api/generate-client-id.ts` uses `Math.random` on purpose — the backend + types `clientId` as a numeric string (`zodBigintAsString()`), so a UUID would fail server + validation. Don't "upgrade" it to `crypto.randomUUID()`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5192119 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# chatbotx-mobile-app + +Expo + TypeScript mobile client for [ChatbotX](https://chatbotx.io/) — the open-source omnichannel +chatbot platform. + +## ✨ Features + +- **Auth & workspaces:** sign in, switch workspaces, workspace picker +- **Live Chat:** Conversations tab with attachments, replies, edit/delete, and read state +- **Contact CRM:** Contacts tab with tags, custom fields, and PII masking for restricted roles +- **Realtime:** PartyKit-backed live updates for messages and conversation state +- **Push notifications:** Expo push + Android FCM, with deep-linking into a conversation/contact +- **i18n:** 20 locales, including RTL support for `ar`/`he` +- **Typed API client:** REST client generated from the backend's OpenAPI spec + +## Tech Stack + +- [Expo](https://expo.dev) (SDK 57) + [expo-router](https://docs.expo.dev/router/introduction/) +- TypeScript, strict mode +- [TanStack Query](https://tanstack.com/query) for server state +- [Zustand](https://zustand-demo.pmnd.rs/) for client state +- [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) + [openapi-typescript](https://openapi-ts.dev/) for the typed REST client +- [i18next](https://www.i18next.com/) / [react-i18next](https://react.i18next.com/) for translations +- [expo-secure-store](https://docs.expo.dev/versions/latest/sdk/securestore/) for token storage + +There is no web target — this app ships iOS/Android only. + +## Quick Start + +```bash +pnpm install +cp .env.example .env +# edit API_BASE_URL if the backend isn't on the default local port (http://localhost:3123) +pnpm start +``` + +Then follow the Expo CLI prompts to open the app in a development build, Android emulator, iOS +simulator, or Expo Go. + +## Development Commands + +```bash +pnpm ios # expo run:ios +pnpm android # expo run:android +pnpm lint # expo lint (eslint-config-expo) +pnpm typecheck # tsc --noEmit +pnpm test # jest +pnpm format # prettier --write . +pnpm format:check # prettier --check . +pnpm generate:api # regenerate the typed API client from a running backend instance +``` + +## Docs + +- [Project structure](docs/project-structure.md) +- [Regenerating the API client](docs/api-client.md) +- [Realtime event types](docs/realtime-events.md) +- [Android push notifications](docs/push-notifications.md) +- [EAS Update](docs/eas-updates.md) diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..3593f1c --- /dev/null +++ b/app.config.ts @@ -0,0 +1,121 @@ +import { existsSync } from 'node:fs'; + +import type { ExpoConfig } from 'expo/config'; + +/** + * Using app.config.ts (instead of a static app.json) so we can read process.env at build/start + * time and expose runtime-configurable values (like the API base URL) via `expo-constants`. + * + * Values consumed by the app at runtime must be read through `expo-constants` + * (see src/config/env.ts) — process.env is only available here, in the Expo config context. + */ +const config: ExpoConfig = { + name: 'ChatbotX', + slug: 'chatbotx-mobile-app', + version: '1.0.0', + orientation: 'portrait', + icon: './assets/images/icon.png', + scheme: 'chatbotxmobileapp', + userInterfaceStyle: 'automatic', + // New Architecture is mandatory (no opt-out) as of this SDK — no `newArchEnabled` property + // exists in the config schema to set. + ios: { + icon: './assets/expo.icon', + bundleIdentifier: 'com.chatconnectx.mobile', + }, + android: { + package: 'com.chatconnectx.mobile', + adaptiveIcon: { + backgroundColor: '#E6F4FE', + foregroundImage: './assets/images/android-icon-foreground.png', + backgroundImage: './assets/images/android-icon-background.png', + monochromeImage: './assets/images/android-icon-monochrome.png', + }, + predictiveBackGestureEnabled: false, + // Edge-to-edge display is mandatory (no opt-out) as of this SDK — no `edgeToEdgeEnabled` knob + // exists in the Android config schema to set. + // Required for Android FCM V1 push delivery — prerequisite: a Firebase project with this + // package name registered, its google-services.json downloaded to the repo root (gitignored, + // not committed). Conditional on the file actually existing locally: it's a per-developer/CI + // secret (see README for the EAS file-secret setup), and an unconditional reference here fails + // every clean checkout's Android build/prebuild before that secret is provisioned. + googleServicesFile: existsSync('./google-services.json') ? './google-services.json' : undefined, + }, + plugins: [ + 'expo-router', + 'expo-font', + [ + 'expo-splash-screen', + { + backgroundColor: '#208AEF', + image: './assets/images/splash-icon.png', + imageWidth: 76, + }, + ], + 'expo-image', + 'expo-web-browser', + 'expo-secure-store', + [ + 'expo-image-picker', + { + photosPermission: 'Allow $(PRODUCT_NAME) to access your photos to attach them to messages.', + }, + ], + [ + 'expo-camera', + { + cameraPermission: + 'Allow $(PRODUCT_NAME) to access your camera to attach photos to messages.', + }, + ], + [ + 'expo-localization', + { + // Native RTL layout for `ar`/`he`. Requires a new dev/production build to take effect — + // Expo Go can't apply native Info.plist/strings.xml changes, so `extra.supportsRTL` below + // mirrors this for the app's own Expo-Go-safe RTL bootstrap logic (see src/i18n/reconcile-rtl.ts). + supportsRTL: true, + }, + ], + [ + 'expo-notifications', + { + // No dedicated notification icon asset exists yet (Android requires a flat white + // silhouette, distinct from the app icon) — falls back to Expo's generated default until + // one is designed. `color` matches the splash-screen brand color. + color: '#208AEF', + }, + ], + 'expo-updates', + ], + experiments: { + typedRoutes: true, + reactCompiler: true, + }, + // EAS Update: OTA JS-bundle updates without an app-store resubmission. `runtimeVersion` gates + // which updates a given build can receive — 'appVersion' policy means any build sharing the + // same `version` above can receive the same update (no native code change assumed between + // updates to the same version). + runtimeVersion: { policy: 'appVersion' }, + updates: { + url: 'https://u.expo.dev/8cfd58a8-15f8-4f64-b16c-abf26166e80f', + }, + extra: { + // Falls back to the backend's default local dev URL. Override via `API_BASE_URL` env var + // (e.g. in `.env`, loaded by `expo start` through the standard EXPO_PUBLIC_ / dotenv support, + // or exported in the shell before running Expo CLI commands). + apiBaseUrl: process.env.API_BASE_URL ?? 'http://localhost:3123', + // Realtime PartyKit server. Web reads this from tenant settings; for mobile dev we default to + // the local partykit dev port until tenant-settings discovery is wired up. + wsUrl: process.env.WS_URL ?? 'http://localhost:1999', + // Mirrors the expo-localization plugin's supportsRTL above for Expo Go, which can't read the + // native Info.plist/strings.xml values the plugin writes at build time. + supportsRTL: true, + // Written by `eas init` — required by `getExpoPushTokenAsync` in src/lib/notifications.ts. + eas: { + projectId: '8cfd58a8-15f8-4f64-b16c-abf26166e80f', + }, + }, +}; + +export default config; diff --git a/assets/expo.icon/Assets/expo-symbol 2.svg b/assets/expo.icon/Assets/expo-symbol 2.svg new file mode 100644 index 0000000..51d3676 --- /dev/null +++ b/assets/expo.icon/Assets/expo-symbol 2.svg @@ -0,0 +1,3 @@ +<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/> +</svg> diff --git a/assets/expo.icon/Assets/grid.png b/assets/expo.icon/Assets/grid.png new file mode 100644 index 0000000..eefea24 Binary files /dev/null and b/assets/expo.icon/Assets/grid.png differ diff --git a/assets/expo.icon/icon.json b/assets/expo.icon/icon.json new file mode 100644 index 0000000..9a26f70 --- /dev/null +++ b/assets/expo.icon/icon.json @@ -0,0 +1,35 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.47843,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "expo-symbol 2.svg", + "name": "expo-symbol 2", + "position": { + "scale": 1, + "translation-in-points": [1.1008400065293245e-5, -16.046875] + } + }, + { + "image-name": "grid.png", + "name": "grid" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/assets/images/android-icon-background.png b/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/assets/images/android-icon-background.png differ diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/assets/images/android-icon-foreground.png differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/assets/images/android-icon-monochrome.png differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/assets/images/favicon.png differ diff --git a/assets/images/icon.png b/assets/images/icon.png new file mode 100644 index 0000000..67c777a Binary files /dev/null and b/assets/images/icon.png differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png new file mode 100644 index 0000000..6b1642a Binary files /dev/null and b/assets/images/splash-icon.png differ diff --git a/docs/api-client.md b/docs/api-client.md new file mode 100644 index 0000000..d31a33f --- /dev/null +++ b/docs/api-client.md @@ -0,0 +1,15 @@ +# Regenerating the API client + +```bash +pnpm generate:api +``` + +This fetches `{API_BASE_URL}/api/spec.json` (the full oRPC router spec, including the +bearer/session-authenticated routes the app needs — override with `API_SPEC_PATH` to point at the +workspace-token-only `/api/public-spec.json` instead) from a running backend instance and runs it +through `openapi-typescript` to produce `src/api/generated/schema.ts`, which `src/api/client.ts` +consumes via `openapi-fetch`. + +The script is **not** wired into the build — the backend isn't guaranteed to be running, and this +repo must build without it. If the spec endpoint isn't reachable, the script prints a clear +message and exits non-zero without touching the previously generated file. diff --git a/docs/eas-updates.md b/docs/eas-updates.md new file mode 100644 index 0000000..d9ea242 --- /dev/null +++ b/docs/eas-updates.md @@ -0,0 +1,6 @@ +# EAS Update + +This app ships with `expo-updates` configured (`app.config.ts`'s `updates`/`runtimeVersion`), so +production/preview builds can receive OTA JS-bundle updates via `eas update` without an app-store +resubmission. `runtimeVersion` uses the `appVersion` policy — a build only receives updates +published against the same `version` in `app.config.ts`. diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..132a42d --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,22 @@ +# Project structure + +``` +src/ + api/ typed REST client (openapi-fetch), auth token storage, big-number-safe JSON parsing + generated/ output of `pnpm generate:api` — do not hand-edit + app/ expo-router screens (file-based routing) + components/ui/ design-system primitives (Button, Text, ListItem, Sheet, Skeleton, etc.) + config/ runtime env config (API_BASE_URL via expo-constants) + dev/ dev-build-only screens' content (e.g. the UI gallery), lazily required so their + imports don't ship in production bundles + features/ feature modules (chat, contacts, conversations, workspaces, settings, ...) — + each owns its own api/ (TanStack Query hooks), components/, and stores/ + i18n/ i18next setup, RTL reconciliation, and locale files (20 locales) + lib/ shared library wiring (TanStack Query client, push notifications, deep links) + realtime/ realtime event types mirrored from the backend (type-only, manual sync) + stores/ app-wide Zustand client-state stores (auth, workspace, settings) + theme/ design tokens, the useTheme() hook, and motion/reduced-motion helpers +scripts/ + generate-api-client.ts regenerates src/api/generated/schema.ts from the backend's OpenAPI spec + i18n-sync.ts backfills missing translation keys across all 20 locales from en.json +``` diff --git a/docs/push-notifications.md b/docs/push-notifications.md new file mode 100644 index 0000000..8dd8cac --- /dev/null +++ b/docs/push-notifications.md @@ -0,0 +1,12 @@ +# Android push notifications (`google-services.json`) + +Android FCM V1 push delivery requires a Firebase project with this app's package name +(`com.chatconnectx.mobile`) registered, and its `google-services.json` downloaded to the repo +root — the file is gitignored (per-developer/CI secret, never committed). `app.config.ts` only +references it conditionally (`existsSync` check), so a clean checkout without the file still +builds; Android push registration simply won't work until it's provisioned. + +For EAS builds, upload it as a +[file-type secret](https://docs.expo.dev/eas/environment-variables/#file-environment-variables) +via `eas env:create` (or the EAS dashboard) referencing the path `./google-services.json`, rather +than committing it. diff --git a/docs/realtime-events.md b/docs/realtime-events.md new file mode 100644 index 0000000..e037250 --- /dev/null +++ b/docs/realtime-events.md @@ -0,0 +1,9 @@ +# Realtime event types + +`src/realtime/events.ts` mirrors the backend's realtime event type definitions **by hand**. This +app lives in a separate git repository from the backend (not a shared pnpm workspace), so there's +no live cross-repo import — copying types in is the intentionally simple, non-fragile choice over +a symlink or git submodule. Only type-only shapes are mirrored; no server-side code is referenced. + +When the backend's event schemas change, re-copy the updated shapes by hand. The header comment in +`src/realtime/events.ts` notes which commit it was last synced against. diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..c9b1494 --- /dev/null +++ b/eas.json @@ -0,0 +1,36 @@ +{ + "cli": { + "version": ">= 13.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "channel": "development", + "env": { + "API_BASE_URL": "http://localhost:3123", + "WS_URL": "http://localhost:1999" + } + }, + "preview": { + "distribution": "internal", + "channel": "preview", + "env": { + "API_BASE_URL": "https://preview.example.com", + "WS_URL": "https://preview-realtime.example.com" + } + }, + "production": { + "autoIncrement": true, + "channel": "production", + "env": { + "API_BASE_URL": "https://api.example.com", + "WS_URL": "https://realtime.example.com" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5025da6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, +]); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..8d17119 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +module.exports = { + preset: 'jest-expo', + setupFiles: ['./jest.setup.ts'], + moduleNameMapper: { + '^@/(.*)$': '<rootDir>/src/$1', + }, + // react-native-worklets (reanimated 4's underlying worklets runtime) ships a real native + // module with no Jest mock; its own package provides this resolver, which steers Jest away + // from the `.native.ts` entrypoints (which throw under Jest — see react-native-worklets' + // NativeWorklets.native.ts) toward its JS-only fallback path instead. Without this, importing + // anything that pulls in `react-native-reanimated` (e.g. any UI primitive built on + // PressableScale) crashes at import time under Jest. + resolver: 'react-native-worklets/jest/resolver.js', + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/api/generated/**', '!src/**/*.d.ts'], +}; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..fe72a1e --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,81 @@ +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); + +// src/config/env.ts reads Expo config at MODULE LOAD and throws when `extra` is absent, so any +// test importing a module that transitively pulls in `env` (e.g. api/client.ts) fails to load +// without this. app.config.ts is not evaluated under Jest, so supply the `extra` block here. +jest.mock('expo-constants', () => ({ + __esModule: true, + default: { + expoConfig: { + extra: { + apiBaseUrl: 'https://test.invalid', + wsUrl: 'wss://test.invalid', + }, + }, + }, +})); + +// expo-secure-store is a native module with no Jest preset mock; src/api/auth-token.ts (and +// anything transitively importing src/api/client.ts) throws at import time without this. An +// in-memory Map is enough for tests that don't specifically assert persistence behavior. +jest.mock('expo-secure-store', () => { + const store = new Map<string, string>(); + return { + __esModule: true, + getItemAsync: async (key: string) => store.get(key) ?? null, + setItemAsync: async (key: string, value: string) => { + store.set(key, value); + }, + deleteItemAsync: async (key: string) => { + store.delete(key); + }, + }; +}); + +// expo-image renders a native view manager with no Jest preset mock; any component that renders +// <Avatar> (which uses expo-image's <Image>) throws "requireNativeViewManager is not available" +// under Jest without this. A plain react-native <Image> stand-in is enough for component tests +// that don't assert on image-loading behavior specifically. +jest.mock('expo-image', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.mock factories cannot + // reference out-of-scope imports; requiring inline is the standard Jest pattern. + const { Image: RNImage } = require('react-native'); + return { + __esModule: true, + Image: RNImage, + }; +}); + +// expo-crypto is a native module with no Jest preset mock; stub it with Node's own `crypto` so +// src/lib/install-crypto-polyfill.ts can be exercised without a device/simulator. +jest.mock('expo-crypto', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.mock factories cannot + // reference out-of-scope imports; requiring inline is the standard Jest pattern. + const nodeCrypto = require('node:crypto'); + return { + __esModule: true, + randomUUID: () => nodeCrypto.randomUUID(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock-only; expo-crypto's + // real generic signature is asserted separately by src/lib/install-crypto-polyfill.ts. + getRandomValues: (typedArray: any) => nodeCrypto.webcrypto.getRandomValues(typedArray), + }; +}); + +// expo-image-picker and expo-document-picker are native modules with no Jest preset mock (unlike +// expo-file-system's class-based File API, which the preset does special-case) — any test +// importing src/features/chat/lib/pick-attachments.ts (composer.tsx, camera.tsx, or the module +// itself) throws "Cannot find native module 'ExponentImagePicker'"/"...'ExpoDocumentPicker'" +// without these. Bare jest.fn() stand-ins are enough; tests that need specific picker results +// override these with jest.mock(...).mockResolvedValueOnce(...) locally. +jest.mock('expo-image-picker', () => ({ + __esModule: true, + launchImageLibraryAsync: jest.fn(), + launchCameraAsync: jest.fn(), + requestMediaLibraryPermissionsAsync: jest.fn(), +})); +jest.mock('expo-document-picker', () => ({ + __esModule: true, + getDocumentAsync: jest.fn(), +})); diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd2e309 --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "chatbotx-mobile-app", + "main": "expo-router/entry", + "version": "1.0.0", + "packageManager": "pnpm@11.15.1", + "dependencies": { + "@expo-google-fonts/plus-jakarta-sans": "^0.4.2", + "@expo/vector-icons": "^15.1.1", + "@gorhom/bottom-sheet": "^5.2.14", + "@react-native-async-storage/async-storage": "2.2.0", + "@shopify/flash-list": "2.0.2", + "@tanstack/react-query": "^5.101.4", + "dayjs": "^1.11.23", + "expo": "~57.0.17", + "expo-camera": "~57.0.4", + "expo-constants": "~57.0.15", + "expo-crypto": "~57.0.2", + "expo-dev-client": "~57.0.16", + "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", + "expo-file-system": "~57.0.6", + "expo-font": "~57.0.1", + "expo-haptics": "~57.0.2", + "expo-image": "~57.0.3", + "expo-image-picker": "~57.0.14", + "expo-linking": "~57.0.8", + "expo-localization": "^57.0.1", + "expo-notifications": "~57.0.15", + "expo-router": "~57.0.17", + "expo-secure-store": "^57.0.2", + "expo-splash-screen": "~57.0.8", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.3", + "expo-updates": "~57.0.18", + "expo-web-browser": "~57.0.2", + "i18next": "^26.3.6", + "openapi-fetch": "^0.17.0", + "partysocket": "^1.3.0", + "react": "19.2.3", + "react-i18next": "^17.0.11", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-worklets": "0.10.1", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@testing-library/react-native": "^14.0.1", + "@types/jest": "^29.5.14", + "@types/react": "~19.2.2", + "dotenv": "^17.4.2", + "eslint": "^9.39.5", + "eslint-config-expo": "^57.0.2", + "jest": "^29.7.0", + "jest-expo": "^57.0.5", + "openapi-typescript": "^7.13.0", + "prettier": "^3.9.6", + "tsx": "^4.23.12", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "lint": "expo lint", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", + "generate:api": "tsx scripts/generate-api-client.ts", + "test": "jest", + "test:watch": "jest --watch" + }, + "private": true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6a89ff7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,11423 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@expo-google-fonts/plus-jakarta-sans': + specifier: ^0.4.2 + version: 0.4.2 + '@expo/vector-icons': + specifier: ^15.1.1 + version: 15.1.1(expo-font@57.0.1)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@gorhom/bottom-sheet': + specifier: ^5.2.14 + version: 5.2.14(c57327f6cff0368afd765304bb73382e) + '@react-native-async-storage/async-storage': + specifier: 2.2.0 + version: 2.2.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + '@shopify/flash-list': + specifier: 2.0.2 + version: 2.0.2(@babel/runtime@7.29.7)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@tanstack/react-query': + specifier: ^5.101.4 + version: 5.101.4(react@19.2.3) + dayjs: + specifier: ^1.11.23 + version: 1.11.23 + expo: + specifier: ~57.0.17 + version: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-camera: + specifier: ~57.0.4 + version: 57.0.4(@types/emscripten@1.41.5)(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-constants: + specifier: ~57.0.15 + version: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + expo-crypto: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.17) + expo-dev-client: + specifier: ~57.0.16 + version: 57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-device: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.17) + expo-document-picker: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.17) + expo-file-system: + specifier: ~57.0.6 + version: 57.0.6(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-font: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-haptics: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.17) + expo-image: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-image-picker: + specifier: ~57.0.14 + version: 57.0.14(expo@57.0.17) + expo-linking: + specifier: ~57.0.8 + version: 57.0.8(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + expo-localization: + specifier: ^57.0.1 + version: 57.0.1(expo@57.0.17)(react@19.2.3) + expo-notifications: + specifier: ~57.0.15 + version: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-router: + specifier: ~57.0.17 + version: 57.0.17(d6f39c1ccc936eb2d412636bfc344e3d) + expo-secure-store: + specifier: ^57.0.2 + version: 57.0.2(expo@57.0.17) + expo-splash-screen: + specifier: ~57.0.8 + version: 57.0.8(expo@57.0.17)(supports-color@10.2.2)(typescript@6.0.3) + expo-status-bar: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-system-ui: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + expo-updates: + specifier: ~57.0.18 + version: 57.0.18(expo-dev-client@57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)))(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + expo-web-browser: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@6.0.3) + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 + partysocket: + specifier: ^1.3.0 + version: 1.3.0(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-i18next: + specifier: ^17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3) + react-native: + specifier: 0.86.3 + version: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-gesture-handler: + specifier: ~2.32.0 + version: 2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-reanimated: + specifier: 4.5.1 + version: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-safe-area-context: + specifier: ~5.7.0 + version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-screens: + specifier: ~4.26.0 + version: 4.26.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-worklets: + specifier: 0.10.1 + version: 0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + zustand: + specifier: ^5.0.15 + version: 5.0.15(@types/react@19.2.18)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + devDependencies: + '@testing-library/react-native': + specifier: ^14.0.1 + version: 14.0.1(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(test-renderer@1.2.0(@types/react@19.2.18)(react@19.2.3)) + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/react': + specifier: ~19.2.2 + version: 19.2.18 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + eslint: + specifier: ^9.39.5 + version: 9.39.5(supports-color@10.2.2) + eslint-config-expo: + specifier: ^57.0.2 + version: 57.0.2(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + jest-expo: + specifier: ^57.0.5 + version: 57.0.5(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(expo@57.0.17)(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@6.0.3) + prettier: + specifier: ^3.9.6 + version: 3.9.6 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.29.7': + resolution: {integrity: sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.29.7': + resolution: {integrity: sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.29.7': + resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.29.7': + resolution: {integrity: sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.29.7': + resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.29.7': + resolution: {integrity: sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.7': + resolution: {integrity: sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@expo-google-fonts/material-symbols@0.4.44': + resolution: {integrity: sha512-36JP9Chcy/QEVZ9ZGY4i6zInlyFPbQjkIm6gRNuWWltScIj0WR8rddcD57EIjSC5YKCe2ZKLO6eO/N5r8Jit0A==} + + '@expo-google-fonts/plus-jakarta-sans@0.4.2': + resolution: {integrity: sha512-6LYVmVGwjQvH+uzzWlVc9+oMj4lkNQ41aymVDjO+x8aFk8kCye20wOyLomYMZaMezA++Uf1mZRCw3W3Fy/hxEA==} + + '@expo/cli@57.0.19': + resolution: {integrity: sha512-HcvVmm0FNL1P3Lf23z1L/sLZtdcWfKW8P5L/doQbICWVG/PL7uI5+G2Ttxnj9QGNmpYgJ89E7+LJefS5jJMUlQ==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@57.0.9': + resolution: {integrity: sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==} + + '@expo/config-types@57.0.2': + resolution: {integrity: sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==} + + '@expo/config@57.0.9': + resolution: {integrity: sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@57.0.1': + resolution: {integrity: sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@57.0.1': + resolution: {integrity: sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.4.2': + resolution: {integrity: sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==} + engines: {node: '>=20.12.0'} + + '@expo/expo-modules-macros-plugin@0.6.1': + resolution: {integrity: sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==} + + '@expo/fingerprint@0.20.10': + resolution: {integrity: sha512-Ox/smKlpPpyTUwZAXS927xuJ8qICwNxHOtZElv0anp6xJ80FzwkfvCdC/NjCxlALS2Hp3IsmwyKd975gYgW12Q==} + hasBin: true + + '@expo/image-utils@0.11.5': + resolution: {integrity: sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==} + + '@expo/inline-modules@0.1.7': + resolution: {integrity: sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==} + + '@expo/json-file@11.0.1': + resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} + + '@expo/local-build-cache-provider@57.0.8': + resolution: {integrity: sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==} + + '@expo/log-box@57.0.4': + resolution: {integrity: sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==} + peerDependencies: + '@expo/dom-webview': ^57.0.1 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@57.0.11': + resolution: {integrity: sha512-aIKcCAQALafeIf+HOoHgjj7hOb771VQRFZARDjm+GJRT74Lb3bYHjkATtS4Wv2zqVx/aFYkNqYNqPF/Py3kSlw==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-file-map@57.0.2': + resolution: {integrity: sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg==} + + '@expo/metro-runtime@57.0.14': + resolution: {integrity: sha512-LVpsfF0QOzlLSKlHYpxgW1cP1NKcYeaoalLO18jYHgqseSveEkb9VOffFpicTQ7AN03odZ4fgkUF+p5YpoY4yw==} + peerDependencies: + '@expo/log-box': ^57.0.4 + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@56.0.2': + resolution: {integrity: sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==} + + '@expo/osascript@2.7.1': + resolution: {integrity: sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==} + engines: {node: '>=12'} + + '@expo/package-manager@1.13.1': + resolution: {integrity: sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==} + + '@expo/plist@0.8.1': + resolution: {integrity: sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==} + + '@expo/prebuild-config@57.0.15': + resolution: {integrity: sha512-xTbWHroj0PDmlbqvmU+zF9ZZxveJkiuyiPoeRJYRGruFHebRAWnoTdw5S7d/UCzDBI8ropGGu9g2eb2nMxtvAw==} + + '@expo/require-utils@57.0.5': + resolution: {integrity: sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@57.0.8': + resolution: {integrity: sha512-lEPGYoDmh97yyEcY/MFWQsfE7yIgMRnGDcnsBN7B6AiWBFSJIMEcGnO0aYPgY0ZjNWyJDi7Ee8QZRGqB+LhI6w==} + peerDependencies: + '@expo/metro-runtime': ^57.0.14 + expo: '*' + expo-constants: ^57.0.15 + expo-font: ^57.0.1 + expo-router: '*' + expo-server: ^57.0.3 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@57.0.2': + resolution: {integrity: sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/ui@57.0.14': + resolution: {integrity: sha512-m8Z5dCplkOOQ70lu/xfB67qhk00Muw4U1T9frHBIW3LxxJeJZQ+jOmV6vCk6O6zVgoG/ib1et7PeX6etxI4Rsw==} + peerDependencies: + '@babel/core': '*' + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-worklets: '*' + peerDependenciesMeta: + '@babel/core': + optional: true + react-dom: + optional: true + react-native-worklets: + optional: true + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@2.0.0': + resolution: {integrity: sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==} + peerDependencies: + ws: ^8.0.0 + + '@expo/xcpretty@4.4.4': + resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} + hasBin: true + + '@gorhom/bottom-sheet@5.2.14': + resolution: {integrity: sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg==} + peerDependencies: + '@types/react': '*' + '@types/react-native': '*' + react: '*' + react-native: '*' + react-native-gesture-handler: '>=2.16.1' + react-native-reanimated: '>=3.16.0 || >=4.0.0-' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-native': + optional: true + + '@gorhom/portal@1.0.14': + resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==} + peerDependencies: + react: '*' + react-native: '*' + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + + '@react-native-masked-view/masked-view@0.3.2': + resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} + peerDependencies: + react: '>=16' + react-native: '>=0.57' + + '@react-native/assets-registry@0.86.3': + resolution: {integrity: sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-plugin-codegen@0.86.3': + resolution: {integrity: sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.86.3': + resolution: {integrity: sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.86.3': + resolution: {integrity: sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.86.3': + resolution: {integrity: sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': 0.86.3 + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.86.3': + resolution: {integrity: sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/debugger-shell@0.86.3': + resolution: {integrity: sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/dev-middleware@0.86.3': + resolution: {integrity: sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/gradle-plugin@0.86.3': + resolution: {integrity: sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/jest-preset@0.86.3': + resolution: {integrity: sha512-oMmBwXUpTgJvGlcXQQPuiQLykoM8jsWYp6RwdJ2NWyIWzJcGp1rmSr3XV7istoIcUt0wdOVLtQs0A0nDm9ND+A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + react: ^19.2.3 + + '@react-native/js-polyfills@0.86.3': + resolution: {integrity: sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.86.3': + resolution: {integrity: sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.86.3': + resolution: {integrity: sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.86.3': + resolution: {integrity: sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==} + + '@react-native/virtualized-lists@0.86.3': + resolution: {integrity: sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: 0.86.3 + peerDependenciesMeta: + '@types/react': + optional: true + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.19': + resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@shopify/flash-list@2.0.2': + resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@tanstack/query-core@5.101.4': + resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} + + '@tanstack/react-query@5.101.4': + resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/react-native@14.0.1': + resolution: {integrity: sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==} + engines: {node: ^22.13.0 || >=24} + peerDependencies: + jest: '>=29.0.0' + react: '>=19.0.0' + react-native: '>=0.78' + test-renderer: ^1.0.0 + peerDependenciesMeta: + jest: + optional: true + + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@xmldom/xmldom@0.8.14': + resolution: {integrity: sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + + '@xmldom/xmldom@0.9.11': + resolution: {integrity: sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==} + engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-cli-detector@0.1.6: + resolution: {integrity: sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA==} + engines: {node: '>=18.18'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.36.0: + resolution: {integrity: sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==} + + babel-plugin-syntax-hermes-parser@0.36.1: + resolution: {integrity: sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@57.0.9: + resolution: {integrity: sha512-T19biGOBTnMp161PyBqWOoSIEeZug8/EjtuUdVob8JPQKZMTalHUQaQt+qtQQE7XsEkZJ6erb3k0CohneTFzQg==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^57.0.13 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + barcode-detector@3.2.2: + resolution: {integrity: sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.15: + resolution: {integrity: sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==} + engines: {node: '>=6.0.0'} + hasBin: true + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + char-regex@2.0.2: + resolution: {integrity: sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg==} + engines: {node: '>=12.20'} + + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + + chromium-edge-launcher@0.3.0: + resolution: {integrity: sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dnssd-advertise@1.1.6: + resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.411: + resolution: {integrity: sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-config-expo@57.0.2: + resolution: {integrity: sha512-dOWkx+MWclLVnYpPPBzas4sfnPaAjk+fe/dbvJSzbWtHUR4fGGaTT/lTcOpxqhwRIlTKKBUYGKude0tcTBsZHg==} + peerDependencies: + eslint: '>=8.10' + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-expo@1.1.0: + resolution: {integrity: sha512-vPP0EPx7IA7ZfP49dY4rq9RV5jqkFWG+Pih3/oGjzIRjMI+ogcOE8i6isYkLXAdw/yvFV2BRZkTaQaiOGQqn6Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + eslint: '>=8.10' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + expo-application@57.0.2: + resolution: {integrity: sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==} + peerDependencies: + expo: '*' + + expo-asset@57.0.15: + resolution: {integrity: sha512-ZoiUftQb1Nn1jvL6l1kZoUXvgRCx57tMQYxSsp+g4ZvY+jgVWp/THruXOtPtZVdC/WWZxPUTct53bfAm2EPDAQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-camera@57.0.4: + resolution: {integrity: sha512-MqbbQ63O+cA8JbK3lfFHiD0f2jv8jB+EG/ILK6f5xnOPV9IR37yaG5+lGtRZNoOeYBzGypyOi17USItleMyuGw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-constants@57.0.15: + resolution: {integrity: sha512-rLME1H/K+/uFMR/jEpe/p1nribvCEZZmQegzqxu/R1HTyvYweq4E+B38zB0baie+SEwmeIPLaM4KLVMnejhm5A==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-crypto@57.0.2: + resolution: {integrity: sha512-OGLvHK7Hb7qsONMWGB9pJw3n+iY4OnZQmM/KwQEHRMrrOmNg0Lxj9+tp9bj46aAMMzXx0FTVA2jUKoplpXPnkA==} + peerDependencies: + expo: '*' + + expo-dev-client@57.0.16: + resolution: {integrity: sha512-Txss9sgzEFI0E+4I34E76vPiukeVDHnqCj0ZXy8SDijVM31/P6MCNniVbEZk7MBPQclD0aopfuaCFtkV8+r1sw==} + peerDependencies: + expo: '*' + + expo-dev-launcher@57.0.16: + resolution: {integrity: sha512-jaBr6q4A5js/r465m2mRxN8A87YOi8I6PiIt8XIt4qfQWDgw6hqGovaF1AVFGT1Z5jxkjtgK8WNz/rAeypjqPQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-menu-interface@57.0.0: + resolution: {integrity: sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==} + peerDependencies: + expo: '*' + + expo-dev-menu@57.0.16: + resolution: {integrity: sha512-KzMHtAmnr0GqyU6MCrKLzIo/AfOjcToGhzK+pFeXlcqSPOrPJbTGlZqQ3E1o2G2jZMs+M1XvXh/VgnRyJUaRGw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-device@57.0.1: + resolution: {integrity: sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==} + peerDependencies: + expo: '*' + + expo-document-picker@57.0.1: + resolution: {integrity: sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==} + peerDependencies: + expo: '*' + + expo-eas-client@57.0.2: + resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} + + expo-file-system@57.0.6: + resolution: {integrity: sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@57.0.1: + resolution: {integrity: sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@57.0.1: + resolution: {integrity: sha512-m/n8maxqNcHk6ZDhuqXBfD5Kt1Iz3M8xykVgdB0iSCIXvF70IqWXmQhX8Psswhrp8eZ+3r0mAD0Jh/2gFA3QaA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-haptics@57.0.2: + resolution: {integrity: sha512-vPths6zTxxaGaemC7D1GKbw1iOnOAAL5oAcCFVSgVNWnuLCBefL1LkhRBaJsepiMXRh2y3vwPiXkPaf6d16blA==} + peerDependencies: + expo: '*' + + expo-image-loader@57.0.1: + resolution: {integrity: sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==} + peerDependencies: + expo: '*' + + expo-image-picker@57.0.14: + resolution: {integrity: sha512-NK9XBQqOtscbB/uRts1Gm7Oki6odMN3FQPWD6fSKmNUfKM69O6kcgU25lEXFTSLZ7sLl+AopHa2X0uILd8rjaQ==} + peerDependencies: + expo: '*' + + expo-image@57.0.3: + resolution: {integrity: sha512-EYfV8tIQxXLQPHhgZxc+bESUn2NcVw1U0RM28qqFeFfHyD1sBpIYJC2JTy24OtfBowYVoUyTvgf2ykWydiZVCw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-json-utils@57.0.1: + resolution: {integrity: sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==} + + expo-keep-awake@57.0.1: + resolution: {integrity: sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@57.0.8: + resolution: {integrity: sha512-gv0ZNaF7tILbcvX8xinNbQr6GIGQStxeUAJseweP+LjC9ped7Wl6EeyIMBlDR83U6Da+8drVeqBQSMvwAXgytA==} + peerDependencies: + react: '*' + react-native: '*' + + expo-localization@57.0.1: + resolution: {integrity: sha512-8Ffl4UTbOsQeGT0v5fxMbyPHyPMPnhSPDFQJa8p9rjJrthFoAtNi+fL6Ssmrvf1/7dmPq1mVY52MEt0TMEfgjA==} + peerDependencies: + expo: '*' + react: '*' + + expo-manifests@57.0.1: + resolution: {integrity: sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==} + peerDependencies: + expo: '*' + + expo-modules-autolinking@57.0.12: + resolution: {integrity: sha512-Q8KAlq37nLKsQ+HsS9NpQVpd5jCgqtu694TDUNHBUBpV9ViD82mRBh8Uug/h68RG9xnLS+kuL4nYaCuFRghHjg==} + hasBin: true + + expo-modules-core@57.0.14: + resolution: {integrity: sha512-Cg3aQnVsQhZcMOF/RFgPGzMuhIak5W9eR9ecwmFSeQ813r4/49ut6RMA6PqUuUKuPCUIRqnBAHfSyTm1W9nfzg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-modules-jsi@57.0.6: + resolution: {integrity: sha512-WK0xFEe0FdZTCLCdfXK+HVNYfAmEbA6goecRxqjr0gh3XYRKAr9tikxqcM0ItLQoEPKntscPZhWcPLGvr97Tfw==} + peerDependencies: + react-native: '*' + + expo-notifications@57.0.15: + resolution: {integrity: sha512-H91z4WSFukSQP3dThgh6PCPFrT6VHeR+J2f7CuCOE72JV7YEGcBCir103QY58ocKsdgkodQpnkTU2m8LTHkWLg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-router@57.0.17: + resolution: {integrity: sha512-zKAfGLxrbdkUFzzW1xHezLXPSa0V4DlYKEWweDUUFGgnPWPNy3OY0EKYAqOhlBGSDebAvtnJXn+2zyyJjYKwyg==} + peerDependencies: + '@expo/log-box': ^57.0.4 + '@expo/metro-runtime': ^57.0.14 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^57.0.15 + expo-linking: ^57.0.8 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: ^4.26.0 + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@57.0.2: + resolution: {integrity: sha512-PhrPMKnI7YSObLEQZOoP9evB2ZOCv9kFuG2L7cZfNlOwWjGbir711PlppkXVoM3JE8vamDrTqzMLGv266FhJJg==} + peerDependencies: + expo: '*' + + expo-server@57.0.3: + resolution: {integrity: sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==} + engines: {node: '>=20.16.0'} + + expo-splash-screen@57.0.8: + resolution: {integrity: sha512-BEsrKg4niYBZa5AFzWRyGqxA4ZemtBOPwbCy+C336iGYYPuZYLEgWJGmm2xD1gRfCdrM/TP+l2ONd86Zvk3KyA==} + peerDependencies: + expo: '*' + + expo-status-bar@57.0.1: + resolution: {integrity: sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-structured-headers@57.0.0: + resolution: {integrity: sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==} + + expo-symbols@57.0.2: + resolution: {integrity: sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo-system-ui@57.0.3: + resolution: {integrity: sha512-3N3BoagGojEvQw0h7kL0Lvv2LrVjqEvODSNS+Nc9lj/kzaTaVx4K1HI8qqxeALMf5jui3rLP2b3Ai2pXotoNuA==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-updates-interface@57.0.1: + resolution: {integrity: sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==} + peerDependencies: + expo: '*' + + expo-updates@57.0.18: + resolution: {integrity: sha512-guTp5U6RbMOivi3GWXsflZA6M/4oTZKj1/M69sEzVW9MII/FQt+lPgbbEHwcIGrpXBr4RuJIYVrh492kkptJvQ==} + hasBin: true + peerDependencies: + expo: '*' + expo-dev-client: '*' + react: '*' + react-native: '*' + peerDependenciesMeta: + expo-dev-client: + optional: true + + expo-web-browser@57.0.2: + resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@57.0.17: + resolution: {integrity: sha512-RG9BItgEObzSafkJKHPUh5iCnaPCFXJTDwfEJe3t7uhmqipVhcsf6gAJEtME0xaGaMp4Z3C+HLbPW9DMea8G0w==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-dom: '*' + react-native: '*' + react-native-web: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-dom: + optional: true + react-native-web: + optional: true + react-native-webview: + optional: true + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-compiler@250829098.0.17: + resolution: {integrity: sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + + hermes-estree@0.36.0: + resolution: {integrity: sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + + hermes-parser@0.36.0: + resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-expo@57.0.5: + resolution: {integrity: sha512-U08fwjaDBwnnbKnZcOA0WQbHfKqOWTCGku+IdCaekrLZmfbbZr3P1WqEsXHo6ntidQ3DmW+TT6G1aOp+ChYfKA==} + hasBin: true + peerDependencies: + '@react-native/jest-preset': ^0.86.3 + expo: '*' + react-native: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + expo: + optional: true + react-server-dom-webpack: + optional: true + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watch-select-projects@2.0.0: + resolution: {integrity: sha512-j00nW4dXc2NiCW6znXgFLF9g8PJ0zP25cpQ1xRro/HU2GBfZQFZD0SoXnAlaoKkIY4MlfTMkKGbNXFpvCdjl1w==} + + jest-watch-typeahead@2.2.1: + resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: ^27.0.0 || ^28.0.0 || ^29.0.0 + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + hasBin: true + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multitars@1.0.2: + resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + partysocket@1.3.0: + resolution: {integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==} + peerDependencies: + react: '>=17' + peerDependenciesMeta: + react: + optional: true + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + + react-i18next@17.0.11: + resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react-native-drawer-layout@4.2.10: + resolution: {integrity: sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + + react-native-gesture-handler@2.32.0: + resolution: {integrity: sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.5.1: + resolution: {integrity: sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==} + peerDependencies: + react: '*' + react-native: 0.83 - 0.86 + react-native-worklets: 0.10.x + + react-native-safe-area-context@5.7.0: + resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.26.2: + resolution: {integrity: sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-worklets@0.10.1: + resolution: {integrity: sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==} + peerDependencies: + '@babel/core': '*' + '@react-native/metro-config': '*' + react: '*' + react-native: 0.83 - 0.86 + + react-native@0.86.3: + resolution: {integrity: sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + peerDependencies: + '@react-native/jest-preset': 0.86.3 + '@types/react': ^19.1.1 + react: ^19.2.3 + peerDependenciesMeta: + '@react-native/jest-preset': + optional: true + '@types/react': + optional: true + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} + peerDependencies: + react: ^19.2.3 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + + rtl-detect@1.1.2: + resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sandbox-cli-detector@0.2.0: + resolution: {integrity: sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==} + engines: {node: '>=18.18'} + hasBin: true + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + + stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + + standard-navigation@0.0.5: + resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-length@5.0.1: + resolution: {integrity: sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==} + engines: {node: '>=12.20'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser@5.50.0: + resolution: {integrity: sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + test-renderer@1.2.0: + resolution: {integrity: sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA==} + peerDependencies: + react: ^19.0.0 + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zxing-wasm@3.1.3: + resolution: {integrity: sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@10.2.2) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7(supports-color@10.2.2)': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/traverse': 7.29.8(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@10.2.2)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@10.2.2))': + dependencies: + eslint: 9.39.5(supports-color@10.2.2) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2(supports-color@10.2.2)': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6(supports-color@10.2.2)': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@10.2.2) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@expo-google-fonts/material-symbols@0.4.44': {} + + '@expo-google-fonts/plus-jakarta-sans@0.4.2': {} + + '@expo/cli@57.0.19(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(expo-constants@57.0.15)(expo-font@57.0.1)(expo-router@57.0.17)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/devcert': 1.2.1(supports-color@10.2.2) + '@expo/env': 2.4.2(supports-color@10.2.2) + '@expo/image-utils': 0.11.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/inline-modules': 0.1.7(supports-color@10.2.2)(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/metro': 56.0.2(supports-color@10.2.2) + '@expo/metro-config': 57.0.11(expo@57.0.17)(supports-color@10.2.2)(typescript@6.0.3) + '@expo/metro-file-map': 57.0.2(supports-color@10.2.2) + '@expo/osascript': 2.7.1 + '@expo/package-manager': 1.13.1 + '@expo/plist': 0.8.1 + '@expo/prebuild-config': 57.0.15(supports-color@10.2.2)(typescript@6.0.3) + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/router-server': 57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.15)(expo-font@57.0.1)(expo-router@57.0.17)(expo-server@57.0.3)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@10.2.2) + '@expo/schema-utils': 57.0.2 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 2.0.0(ws@8.21.3) + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.86.3(supports-color@10.2.2) + accepts: 1.3.8 + agent-cli-detector: 0.1.6 + arg: 5.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1(supports-color@10.2.2) + connect: 3.7.0(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + dnssd-advertise: 1.1.6 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-server: 57.0.3 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.2 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.5 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + sandbox-cli-detector: 0.2.0 + semver: 7.8.5 + send: 0.19.2(supports-color@10.2.2) + slugify: 1.6.9 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.21.3 + zod: 3.25.76 + optionalDependencies: + expo-router: 57.0.17(d6f39c1ccc936eb2d412636bfc344e3d) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@57.0.9(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/plist': 0.8.1 + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + getenv: 2.0.0 + glob: 13.0.6 + semver: 7.8.5 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config-types@57.0.2': {} + + '@expo/config@57.0.9(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.5 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1(supports-color@10.2.2)': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@expo/devtools@57.0.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@expo/dom-webview@57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@expo/env@2.4.2(supports-color@10.2.2)': + dependencies: + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/expo-modules-macros-plugin@0.6.1': {} + + '@expo/fingerprint@0.20.10(supports-color@10.2.2)': + dependencies: + '@expo/env': 2.4.2(supports-color@10.2.2) + '@expo/spawn-async': 1.8.0 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.6 + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.11.5(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/inline-modules@0.1.7(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@11.0.1': + dependencies: + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/local-build-cache-provider@57.0.8(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/config': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + anser: 1.4.10 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@57.0.11(expo@57.0.17)(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@expo/config': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/env': 2.4.2(supports-color@10.2.2) + '@expo/json-file': 11.0.1 + '@expo/metro': 56.0.2(supports-color@10.2.2) + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + browserslist: 4.28.8 + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.36.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + resolve-from: 5.0.0 + optionalDependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-file-map@57.0.2(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + fb-watchman: 2.0.2 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + '@expo/metro-runtime@57.0.14(@expo/log-box@57.0.4)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + anser: 1.4.10 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + pretty-format: 29.7.0 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + '@expo/metro@56.0.2(supports-color@10.2.2)': + dependencies: + metro: 0.84.5(supports-color@10.2.2) + metro-babel-transformer: 0.84.5(supports-color@10.2.2) + metro-cache: 0.84.5(supports-color@10.2.2) + metro-cache-key: 0.84.5 + metro-config: 0.84.5(supports-color@10.2.2) + metro-core: 0.84.5 + metro-file-map: 0.84.5(supports-color@10.2.2) + metro-minify-terser: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5(supports-color@10.2.2) + metro-symbolicate: 0.84.5(supports-color@10.2.2) + metro-transform-plugins: 0.84.5(supports-color@10.2.2) + metro-transform-worker: 0.84.5(supports-color@10.2.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.7.1': + dependencies: + '@expo/spawn-async': 1.8.0 + + '@expo/package-manager@1.13.1': + dependencies: + '@expo/json-file': 11.0.1 + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.8.1': + dependencies: + '@xmldom/xmldom': 0.8.14 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@57.0.15(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@expo/config': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/image-utils': 0.11.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@react-native/normalize-colors': 0.86.3 + debug: 4.4.3(supports-color@10.2.2) + expo-modules-autolinking: 57.0.12(supports-color@10.2.2)(typescript@6.0.3) + resolve-from: 5.0.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@57.0.5(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.15)(expo-font@57.0.1)(expo-router@57.0.17)(expo-server@57.0.3)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + expo-font: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-server: 57.0.3 + react: 19.2.3 + optionalDependencies: + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-router: 57.0.17(d6f39c1ccc936eb2d412636bfc344e3d) + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@57.0.2': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/ui@57.0.14(a28db61b81538200eb1a9563fb519989)': + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + react-dom: 19.2.3(react@19.2.3) + react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@expo/vector-icons@15.1.1(expo-font@57.0.1)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + expo-font: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@expo/ws-tunnel@2.0.0(ws@8.21.3)': + dependencies: + ws: 8.21.3 + + '@expo/xcpretty@4.4.4': + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + js-yaml: 4.3.1 + + '@gorhom/bottom-sheet@5.2.14(c57327f6cff0368afd765304bb73382e)': + dependencies: + '@gorhom/portal': 1.0.14(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@gorhom/portal@1.0.14(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + nanoid: 3.3.18 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(supports-color@10.2.2)': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0(supports-color@10.2.2) + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0(supports-color@10.2.2) + jest-runner: 29.7.0(supports-color@10.2.2) + jest-runtime: 29.7.0(supports-color@10.2.2) + jest-snapshot: 29.7.0(supports-color@10.2.2) + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.4.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0(supports-color@10.2.2)': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 26.2.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@29.7.0(supports-color@10.2.2)': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0(supports-color@10.2.2)': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 26.2.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1(supports-color@10.2.2) + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1(supports-color@10.2.2) + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.2.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nolyfill/is-core-module@1.0.39': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-collection@1.1.15(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dialog@1.1.23(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.16(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-portal': 1.1.17(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-focus-scope@1.1.16(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-portal@1.1.17(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-presence@1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-primitive@2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-roving-focus@1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-tabs@1.1.21(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.19(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.18 + + '@react-native-async-storage/async-storage@2.2.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))': + dependencies: + merge-options: 3.0.4 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@react-native-masked-view/masked-view@0.3.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + '@react-native/assets-registry@0.86.3': {} + + '@react-native/babel-plugin-codegen@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@react-native/codegen': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2)) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-syntax-hermes-parser: 0.36.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@10.2.2)) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/parser': 7.29.8 + hermes-parser: 0.36.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.17 + yargs: 17.7.3 + + '@react-native/community-cli-plugin@0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@react-native/dev-middleware': 0.86.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + invariant: 2.2.4 + metro: 0.84.5(supports-color@10.2.2) + metro-config: 0.84.5(supports-color@10.2.2) + metro-core: 0.84.5 + semver: 7.8.5 + optionalDependencies: + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.86.3': {} + + '@react-native/debugger-shell@0.86.3(supports-color@10.2.2)': + dependencies: + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + fb-dotslash: 0.5.8 + transitivePeerDependencies: + - supports-color + + '@react-native/dev-middleware@0.86.3(supports-color@10.2.2)': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.86.3 + '@react-native/debugger-shell': 0.86.3(supports-color@10.2.2) + chrome-launcher: 0.15.2(supports-color@10.2.2) + chromium-edge-launcher: 0.3.0(supports-color@10.2.2) + connect: 3.7.0(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.3(supports-color@10.2.2) + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.86.3': {} + + '@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)': + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/js-polyfills': 0.86.3 + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + jest-environment-node: 29.7.0 + react: 19.2.3 + regenerator-runtime: 0.13.11 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/js-polyfills@0.86.3': {} + + '@react-native/metro-babel-transformer@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@react-native/babel-preset': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + hermes-parser: 0.36.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + '@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + dependencies: + '@react-native/js-polyfills': 0.86.3 + '@react-native/metro-babel-transformer': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + metro-config: 0.84.5(supports-color@10.2.2) + metro-runtime: 0.84.5 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/normalize-colors@0.74.89': + optional: true + + '@react-native/normalize-colors@0.86.3': {} + + '@react-native/virtualized-lists@0.86.3(@types/react@19.2.18)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + optionalDependencies: + '@types/react': 19.2.18 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.19(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rtsao/scc@1.1.0': {} + + '@shopify/flash-list@2.0.2(@babel/runtime@7.29.7)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + tslib: 2.8.1 + + '@sinclair/typebox@0.27.12': {} + + '@sinclair/typebox@0.34.52': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tanstack/query-core@5.101.4': {} + + '@tanstack/react-query@5.101.4(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.101.4 + react: 19.2.3 + + '@testing-library/react-native@14.0.1(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(test-renderer@1.2.0(@types/react@19.2.18)(react@19.2.3))': + dependencies: + jest-matcher-utils: 30.4.1 + picocolors: 1.1.1 + pretty-format: 30.4.1 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + redent: 3.0.0 + test-renderer: 1.2.0(@types/react@19.2.18)(react@19.2.3) + optionalDependencies: + jest: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + + '@tootallnate/once@2.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/emscripten@1.41.5': {} + + '@types/estree@1.0.9': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 26.2.0 + + '@types/hammerjs@2.0.46': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 26.2.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/react-reconciler@0.33.0(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.5(supports-color@10.2.2) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(supports-color@10.2.2) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + eslint: 9.39.5(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.3': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@xmldom/xmldom@0.8.14': {} + + '@xmldom/xmldom@0.9.11': {} + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-globals@7.0.1: + dependencies: + acorn: 8.18.0 + acorn-walk: 8.3.5 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@6.0.2(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agent-cli-detector@0.1.6: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + anser@1.4.10: {} + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@6.2.1: {} + + ansi-regex@4.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1(supports-color@10.2.2) + babel-preset-jest: 29.6.3(@babel/core@7.29.7(supports-color@10.2.2)) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1(supports-color@10.2.2): + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1(supports-color@10.2.2) + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.8 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.36.0: + dependencies: + hermes-parser: 0.36.0 + + babel-plugin-syntax-hermes-parser@0.36.1: + dependencies: + hermes-parser: 0.36.1 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7(supports-color@10.2.2)): + dependencies: + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@10.2.2)): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + + babel-preset-expo@57.0.9(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.7)(expo@57.0.17)(react-refresh@0.14.2)(supports-color@10.2.2): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.36.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@10.2.2)) + debug: 4.4.3(supports-color@10.2.2) + react-refresh: 0.14.2 + optionalDependencies: + '@babel/runtime': 7.29.7 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.29.7(supports-color@10.2.2)): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) + + badgin@1.2.3: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + barcode-detector@3.2.2(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.1.3(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.15: {} + + big-integer@1.6.52: {} + + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.15 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.411 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001809: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case@5.4.4: {} + + char-regex@1.0.2: {} + + char-regex@2.0.2: {} + + chrome-launcher@0.15.2(supports-color@10.2.2): + dependencies: + '@types/node': 26.2.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + chromium-edge-launcher@0.3.0(supports-color@10.2.2): + dependencies: + '@types/node': 26.2.0 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2(supports-color@10.2.2) + mkdirp: 1.0.4 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + + cli-spinners@2.9.2: {} + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + colorette@1.4.0: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1(supports-color@10.2.2): + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9(supports-color@10.2.2) + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + connect@3.7.0(supports-color@10.2.2): + dependencies: + debug: 2.6.9(supports-color@10.2.2) + finalhandler: 1.1.2(supports-color@10.2.2) + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + convert-source-map@2.0.0: {} + + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.8 + + create-jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + optional: true + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dayjs@1.11.23: {} + + debug@2.6.9(supports-color@10.2.2): + dependencies: + ms: 2.0.0 + optionalDependencies: + supports-color: 10.2.2 + + debug@3.2.7(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + detect-node-es@1.1.0: {} + + diff-sequences@29.6.3: {} + + dnssd-advertise@1.1.6: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.411: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.4.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-config-expo@57.0.2(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 9.39.5(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-expo: 1.1.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-react: 7.37.5(eslint@9.39.5(supports-color@10.2.2)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + globals: 16.5.0 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + - typescript + + eslint-import-resolver-node@0.3.10(supports-color@10.2.2): + dependencies: + debug: 3.2.7(supports-color@10.2.2) + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(supports-color@10.2.2) + get-tsconfig: 4.14.3 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + debug: 3.2.7(supports-color@10.2.2) + optionalDependencies: + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 9.39.5(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + eslint-plugin-expo@1.1.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + eslint: 9.39.5(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7(supports-color@10.2.2) + doctrine: 2.1.0 + eslint: 9.39.5(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) + hasown: 2.0.4 + is-core-module: 2.16.2 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/parser': 7.29.8 + eslint: 9.39.5(supports-color@10.2.2) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.5(supports-color@10.2.2)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 9.39.5(supports-color@10.2.2) + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(supports-color@10.2.2): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@10.2.2)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2(supports-color@10.2.2) + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6(supports-color@10.2.2) + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-polyfill@0.0.4: {} + + event-target-shim@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expo-application@57.0.2(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-asset@57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.11.5(supports-color@10.2.2)(typescript@6.0.3) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + - typescript + + expo-camera@57.0.4(@types/emscripten@1.41.5)(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + barcode-detector: 3.2.2(@types/emscripten@1.41.5) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@types/emscripten' + + expo-constants@57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@expo/env': 2.4.2(supports-color@10.2.2) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + expo-crypto@57.0.2(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-dev-client@57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-dev-launcher: 57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-dev-menu: 57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-dev-menu-interface: 57.0.0(expo@57.0.17) + expo-manifests: 57.0.1(expo@57.0.17) + expo-updates-interface: 57.0.1(expo@57.0.17) + transitivePeerDependencies: + - react-native + + expo-dev-launcher@57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + '@expo/schema-utils': 57.0.2 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-dev-menu: 57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-manifests: 57.0.1(expo@57.0.17) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-dev-menu-interface@57.0.0(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-dev-menu@57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-dev-menu-interface: 57.0.0(expo@57.0.17) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-device@57.0.1(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + ua-parser-js: 0.7.41 + + expo-document-picker@57.0.1(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-eas-client@57.0.2: {} + + expo-file-system@57.0.6(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-font@57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + fontfaceobserver: 2.3.0 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-glass-effect@57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-haptics@57.0.2(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-image-loader@57.0.1(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-image-picker@57.0.14(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-image-loader: 57.0.1(expo@57.0.17) + + expo-image@57.0.3(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + expo-json-utils@57.0.1: {} + + expo-keep-awake@57.0.1(expo@57.0.17)(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + + expo-linking@57.0.8(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2): + dependencies: + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - expo + - supports-color + + expo-localization@57.0.1(expo@57.0.17)(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + rtl-detect: 1.1.2 + + expo-manifests@57.0.1(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-json-utils: 57.0.1 + + expo-modules-autolinking@57.0.12(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@expo/require-utils': 57.0.5(supports-color@10.2.2)(typescript@6.0.3) + '@expo/spawn-async': 1.8.0 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + optionalDependencies: + react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + + expo-modules-jsi@57.0.6(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-notifications@57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.11.5(supports-color@10.2.2)(typescript@6.0.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-application: 57.0.2(expo@57.0.17) + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + - typescript + + expo-router@57.0.17(d6f39c1ccc936eb2d412636bfc344e3d): + dependencies: + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/schema-utils': 57.0.2 + '@expo/ui': 57.0.14(a28db61b81538200eb1a9563fb519989) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.21(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 4.0.0 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + expo-glass-effect: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-linking: 57.0.8(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + expo-server: 57.0.3 + expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.18 + query-string: 7.1.3 + react: 19.2.3 + react-fast-compare: 3.2.2 + react-is: 19.2.8 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-drawer-layout: 4.2.10(b4989148c3d1358ee81abc9680ef4d80) + react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-screens: 4.26.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + standard-navigation: 0.0.5 + vaul: 1.1.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + '@testing-library/react-native': 14.0.1(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(test-renderer@1.2.0(@types/react@19.2.18)(react@19.2.3)) + react-dom: 19.2.3(react@19.2.3) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + + expo-secure-store@57.0.2(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-server@57.0.3: {} + + expo-splash-screen@57.0.8(expo@57.0.17)(supports-color@10.2.2)(typescript@6.0.3): + dependencies: + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/image-utils': 0.11.5(supports-color@10.2.2)(typescript@6.0.3) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-status-bar@57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo-structured-headers@57.0.0: {} + + expo-symbols@57.0.2(expo-font@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.44 + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-font: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + sf-symbols-typescript: 2.2.0 + + expo-system-ui@57.0.3(expo@57.0.17)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + '@react-native/normalize-colors': 0.86.3 + debug: 4.4.3(supports-color@10.2.2) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - supports-color + + expo-updates-interface@57.0.1(expo@57.0.17): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + + expo-updates@57.0.18(expo-dev-client@57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)))(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2): + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/plist': 0.8.1 + '@expo/spawn-async': 1.8.0 + arg: 4.1.3 + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + expo-eas-client: 57.0.2 + expo-manifests: 57.0.1(expo@57.0.17) + expo-structured-headers: 57.0.0 + expo-updates-interface: 57.0.1(expo@57.0.17) + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + resolve-from: 5.0.0 + optionalDependencies: + expo-dev-client: 57.0.16(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + transitivePeerDependencies: + - supports-color + + expo-web-browser@57.0.2(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)): + dependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + expo@57.0.17(4c67990895f3488afb1e1e80811ccb84): + dependencies: + '@babel/runtime': 7.29.7 + '@expo/cli': 57.0.19(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(expo-constants@57.0.15)(expo-font@57.0.1)(expo-router@57.0.17)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(supports-color@10.2.2)(typescript@6.0.3) + '@expo/devtools': 57.0.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/fingerprint': 0.20.10(supports-color@10.2.2) + '@expo/local-build-cache-provider': 57.0.8(supports-color@10.2.2)(typescript@6.0.3) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/metro': 56.0.2(supports-color@10.2.2) + '@expo/metro-config': 57.0.11(expo@57.0.17)(supports-color@10.2.2)(typescript@6.0.3) + '@ungap/structured-clone': 1.3.3 + babel-preset-expo: 57.0.9(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.7)(expo@57.0.17)(react-refresh@0.14.2)(supports-color@10.2.2) + expo-asset: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-constants: 57.0.15(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(supports-color@10.2.2) + expo-file-system: 57.0.6(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2)) + expo-font: 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + expo-keep-awake: 57.0.1(expo@57.0.17)(react@19.2.3) + expo-modules-autolinking: 57.0.12(supports-color@10.2.2)(typescript@6.0.3) + expo-modules-core: 57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + pretty-format: 29.7.0 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/dom-webview': 57.0.1(expo@57.0.17)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.17)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: + optional: true + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + optional: true + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fetch-nodeshim@0.4.10: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + filter-obj@1.1.0: {} + + finalhandler@1.1.2(supports-color@10.2.2): + dependencies: + debug: 2.6.9(supports-color@10.2.2) + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + flow-enums-runtime@0.0.6: {} + + fontfaceobserver@2.3.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + getenv@2.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-compiler@250829098.0.17: {} + + hermes-estree@0.25.1: {} + + hermes-estree@0.35.0: {} + + hermes-estree@0.36.0: {} + + hermes-estree@0.36.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + + hermes-parser@0.36.0: + dependencies: + hermes-estree: 0.36.0 + + hermes-parser@0.36.1: + dependencies: + hermes-estree: 0.36.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + html-parse-stringify@4.0.1: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0(supports-color@10.2.2): + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1(supports-color@10.2.2): + dependencies: + agent-base: 6.0.2(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + hyphenate-style-name@1.1.0: + optional: true + + i18next@26.3.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + index-to-position@1.2.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + optional: true + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.4: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.8.5 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@2.2.1: {} + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0(supports-color@10.2.2): + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0(supports-color@10.2.2) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0(supports-color@10.2.2) + jest-snapshot: 29.7.0(supports-color@10.2.2) + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@26.2.0)(supports-color@10.2.2): + dependencies: + '@jest/core': 29.7.0(supports-color@10.2.2) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@26.2.0)(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(supports-color@10.2.2) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0(supports-color@10.2.2) + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 26.2.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0(supports-color@10.2.2): + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 26.2.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3(supports-color@10.2.2) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-expo@57.0.5(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(expo@57.0.17)(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@jest/globals': 29.7.0(supports-color@10.2.2) + '@react-native/jest-preset': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + jest-environment-jsdom: 29.7.0(supports-color@10.2.2) + jest-snapshot: 29.7.0(supports-color@10.2.2) + jest-watch-select-projects: 2.0.0 + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2)) + json5: 2.2.3 + lodash: 4.18.1 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-test-renderer: 19.2.3(react@19.2.3) + server-only: 0.0.1 + stacktrace-js: 2.0.2 + optionalDependencies: + expo: 57.0.17(4c67990895f3488afb1e1e80811ccb84) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - canvas + - jest + - react + - supports-color + - utf-8-validate + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 26.2.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0(supports-color@10.2.2): + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0(supports-color@10.2.2): + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0(supports-color@10.2.2) + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0(supports-color@10.2.2): + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0(supports-color@10.2.2) + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0(supports-color@10.2.2) + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/types': 7.29.8 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watch-select-projects@2.0.0: + dependencies: + ansi-escapes: 4.3.2 + chalk: 3.0.0 + prompts: 2.4.2 + + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2)): + dependencies: + ansi-escapes: 6.2.1 + chalk: 4.1.2 + jest: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + jest-regex-util: 29.6.3 + jest-watcher: 29.7.0 + slash: 5.1.0 + string-length: 5.0.1 + strip-ansi: 7.2.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 26.2.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 26.2.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@26.2.0)(supports-color@10.2.2): + dependencies: + '@jest/core': 29.7.0(supports-color@10.2.2) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@26.2.0)(supports-color@10.2.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jimp-compact@0.16.1: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsc-safe-url@0.2.4: {} + + jsdom@20.0.3(supports-color@10.2.2): + dependencies: + abab: 2.0.6 + acorn: 8.18.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.6 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0(supports-color@10.2.2) + https-proxy-agent: 5.0.1(supports-color@10.2.2) + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.21.3 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + lan-network@0.2.1: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lighthouse-logger@1.4.2(supports-color@10.2.2): + dependencies: + debug: 2.6.9(supports-color@10.2.2) + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.debounce@4.0.8: {} + + lodash.merge@4.6.2: {} + + lodash.throttle@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + marky@1.3.0: {} + + math-intrinsics@1.1.0: {} + + memoize-one@5.2.1: {} + + memoize-one@6.0.0: + optional: true + + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.84.5(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.84.5(supports-color@10.2.2): + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + metro-core: 0.84.5 + transitivePeerDependencies: + - supports-color + + metro-config@0.84.5(supports-color@10.2.2): + dependencies: + connect: 3.7.0(supports-color@10.2.2) + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.5(supports-color@10.2.2) + metro-cache: 0.84.5(supports-color@10.2.2) + metro-core: 0.84.5 + metro-runtime: 0.84.5 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.5 + + metro-file-map@0.84.5(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.50.0 + + metro-resolver@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.5: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.84.5(supports-color@10.2.2): + dependencies: + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.5(supports-color@10.2.2) + nullthrows: 1.1.1 + ob1: 0.84.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.5(supports-color@10.2.2): + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.5(supports-color@10.2.2) + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.5(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.84.5(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + metro: 0.84.5(supports-color@10.2.2) + metro-babel-transformer: 0.84.5(supports-color@10.2.2) + metro-cache: 0.84.5(supports-color@10.2.2) + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5(supports-color@10.2.2) + metro-transform-plugins: 0.84.5(supports-color@10.2.2) + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.5(supports-color@10.2.2): + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/types': 7.29.8 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.5(supports-color@10.2.2) + metro-cache: 0.84.5(supports-color@10.2.2) + metro-cache-key: 0.84.5 + metro-config: 0.84.5(supports-color@10.2.2) + metro-core: 0.84.5 + metro-file-map: 0.84.5(supports-color@10.2.2) + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5(supports-color@10.2.2) + metro-symbolicate: 0.84.5(supports-color@10.2.2) + metro-transform-plugins: 0.84.5(supports-color@10.2.2) + metro-transform-worker: 0.84.5(supports-color@10.2.2) + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@1.2.0: {} + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multitars@1.0.2: {} + + nanoid@3.3.18: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + negotiator@1.0.0: {} + + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + optional: true + + node-forge@1.4.0: {} + + node-int64@0.4.0: {} + + node-releases@2.0.53: {} + + normalize-path@3.0.0: {} + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.5 + validate-npm-package-name: 5.0.1 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nullthrows@1.1.1: {} + + nwsapi@2.2.24: {} + + ob1@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + partysocket@1.3.0(react@19.2.3): + dependencies: + event-target-polyfill: 0.0.4 + optionalDependencies: + react: 19.2.3 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.11 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pluralize@8.0.0: {} + + pngjs@3.4.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: + optional: true + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.8 + + proc-log@4.2.0: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + optional: true + + promise@8.3.0: + dependencies: + asap: 2.0.6 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + + querystringify@2.2.0: {} + + range-parser@1.2.1: {} + + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.10.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.3): + dependencies: + react: 19.2.3 + + react-i18next@17.0.11(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.3.6(typescript@6.0.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + typescript: 6.0.3 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-is@19.2.8: {} + + react-native-drawer-layout@4.2.10(b4989148c3d1358ee81abc9680ef4d80): + dependencies: + color: 4.2.3 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-gesture-handler: 2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + use-latest-callback: 0.2.6(react@19.2.3) + + react-native-gesture-handler@2.32.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.1.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + react-native-is-edge-to-edge@1.3.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + react-native-worklets: 0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + semver: 7.8.5 + + react-native-safe-area-context@5.7.0(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + + react-native-screens@4.26.2(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + react: 19.2.3 + react-freeze: 1.0.4(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + warn-once: 0.1.1 + + react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@babel/runtime': 7.29.7 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + optional: true + + react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/types': 7.29.8 + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + convert-source-map: 2.0.0 + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2) + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2): + dependencies: + '@react-native/assets-registry': 0.86.3 + '@react-native/codegen': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@react-native/community-cli-plugin': 0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(supports-color@10.2.2) + '@react-native/gradle-plugin': 0.86.3 + '@react-native/js-polyfills': 0.86.3 + '@react-native/normalize-colors': 0.86.3 + '@react-native/virtualized-lists': 0.86.3(@types/react@19.2.18)(react-native@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(@react-native/jest-preset@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.36.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.17 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5(supports-color@10.2.2) + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.5 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.13 + yargs: 17.7.3 + optionalDependencies: + '@react-native/jest-preset': 0.86.3(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2) + '@types/react': 19.2.18 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-reconciler@0.33.0(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.18 + + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + react-test-renderer@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + react-is: 19.2.8 + scheduler: 0.27.0 + + react@19.2.3: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-workspace-root@2.0.1: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + + rtl-detect@1.1.2: {} + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sandbox-cli-detector@0.2.0: {} + + sax@1.6.1: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@0.19.2(supports-color@10.2.2): + dependencies: + debug: 2.6.9(supports-color@10.2.2) + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@2.1.0: {} + + serve-static@1.16.3(supports-color@10.2.2): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: + optional: true + + setprototypeof@1.2.0: {} + + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.10.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slash@5.1.0: {} + + slugify@1.6.9: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.6: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-on-first@1.1.0: {} + + sprintf-js@1.0.3: {} + + stable-hash@0.0.5: {} + + stack-generator@2.0.10: + dependencies: + stackframe: 1.3.4 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackframe@1.3.4: {} + + stacktrace-gps@3.1.2: + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + + stacktrace-js@2.0.2: + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + + standard-navigation@0.0.5: {} + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-buffers@2.2.0: {} + + strict-uri-encode@2.0.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-length@5.0.1: + dependencies: + char-regex: 2.0.2 + strip-ansi: 7.2.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + structured-headers@0.4.1: {} + + styleq@0.1.3: + optional: true + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser@5.50.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + test-renderer@1.2.0(@types/react@19.2.18)(react@19.2.3): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.2.18) + react: 19.2.3 + react-reconciler: 0.33.0(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + + throat@5.0.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toqr@0.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@0.0.3: + optional: true + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@0.7.1: {} + + type-fest@4.41.0: {} + + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@6.0.3: {} + + ua-parser-js@0.7.41: {} + + ua-parser-js@1.0.41: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@8.3.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js-replace@1.0.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-latest-callback@0.2.6(react@19.2.3): + dependencies: + react: 19.2.3 + + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.18 + + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + utils-merge@1.0.1: {} + + uuid@7.0.3: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vlq@1.0.1: {} + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: + optional: true + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-fetch@3.6.20: {} + + whatwg-mimetype@3.0.0: {} + + whatwg-url-minimum@0.1.2: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + optional: true + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@7.5.13: {} + + ws@8.21.3: {} + + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + + xml-name-validator@4.0.0: {} + + xml2js@0.6.0: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-ast-parser@0.0.43: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zustand@5.0.15(@types/react@19.2.18)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + + zxing-wasm@3.1.3(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.8.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6436997 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,28 @@ +allowBuilds: + esbuild: true + unrs-resolver: true +minimumReleaseAgeExclude: + - '@expo/cli@57.0.17' + - '@expo/fingerprint@0.20.9' + - '@expo/local-build-cache-provider@57.0.7' + - '@expo/metro-config@57.0.9' + - '@expo/metro-runtime@57.0.12' + - '@expo/prebuild-config@57.0.13' + - '@expo/router-server@57.0.7' + - '@expo/ui@57.0.12' + - expo-asset@57.0.13 + - expo-constants@57.0.13 + - expo-file-system@57.0.5 || 57.0.6 + - expo-linking@57.0.7 + - expo-modules-core@57.0.12 + - expo-modules-jsi@57.0.5 + - expo-router@57.0.15 + - expo@57.0.15 + - expo-camera@57.0.4 + - expo-image-picker@57.0.12 + - expo-updates@57.0.16 + - expo-dev-client@57.0.14 + - expo-dev-launcher@57.0.14 + - expo-dev-menu@57.0.14 + - expo-notifications@57.0.13 + - expo-haptics@57.0.2 diff --git a/scripts/generate-api-client.ts b/scripts/generate-api-client.ts new file mode 100644 index 0000000..b48f333 --- /dev/null +++ b/scripts/generate-api-client.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env -S pnpm exec tsx +/** + * Regenerates the typed API client from the ChatbotX backend's OpenAPI spec. + * + * Usage: + * pnpm generate:api + * API_BASE_URL=https://staging.example.com pnpm generate:api + * API_SPEC_PATH=/api/public-spec.json pnpm generate:api + * + * Fetches `{API_BASE_URL}{API_SPEC_PATH}` (default `/api/spec.json`, the full oRPC router spec — + * includes the bearer/session-authenticated routes the app needs, unlike `/api/public-spec.json` + * which only covers the workspace-token surface) and runs it through openapi-typescript to + * produce src/api/generated/schema.ts (a `paths` type consumed by src/api/client.ts via + * openapi-fetch). + * + * This script is NOT wired into the build — the ChatbotX backend is not guaranteed to be running + * locally, and this repo must be buildable without it. On any failure (spec unreachable, bad + * response, generation error) it prints a clear message and exits non-zero without touching the + * previously generated file. + */ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import dotenv from 'dotenv'; + +dotenv.config(); + +const DEFAULT_API_BASE_URL = 'http://localhost:3123'; +const DEFAULT_SPEC_PATH = '/api/spec.json'; +const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'api', 'generated', 'schema.ts'); +const FETCH_TIMEOUT_MS = 5000; + +function resolveApiBaseUrl(): string { + return process.env.API_BASE_URL ?? DEFAULT_API_BASE_URL; +} + +function resolveSpecPath(): string { + return process.env.API_SPEC_PATH ?? DEFAULT_SPEC_PATH; +} + +async function checkSpecIsReachable(specUrl: string): Promise<void> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(specUrl, { signal: controller.signal }); + + if (!response.ok) { + throw new Error(`Received HTTP ${response.status} ${response.statusText}`); + } + } finally { + clearTimeout(timeout); + } +} + +function runOpenApiTypescript(specUrl: string): Promise<void> { + return new Promise((resolve, reject) => { + const child = spawn('pnpm', ['exec', 'openapi-typescript', specUrl, '--output', OUTPUT_FILE], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`openapi-typescript exited with code ${code}`)); + }); + }); +} + +async function main(): Promise<void> { + const apiBaseUrl = resolveApiBaseUrl(); + const specUrl = new URL(resolveSpecPath(), apiBaseUrl).toString(); + + console.log(`[generate:api] Fetching OpenAPI spec from ${specUrl} ...`); + + try { + await checkSpecIsReachable(specUrl); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(` +[generate:api] Could not reach the OpenAPI spec at ${specUrl}. +[generate:api] Reason: ${reason} +[generate:api] +[generate:api] This is expected if the ChatbotX backend isn't running locally. +[generate:api] Start the backend locally and try again, or set +[generate:api] API_BASE_URL to point at a reachable environment. +[generate:api] +[generate:api] Leaving the previously generated client untouched (${ + existsSync(OUTPUT_FILE) ? 'exists' : 'does not exist yet — using the placeholder type' + }). +`); + process.exitCode = 1; + return; + } + + try { + await runOpenApiTypescript(specUrl); + console.log(`[generate:api] Wrote ${path.relative(process.cwd(), OUTPUT_FILE)}`); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`[generate:api] Generation failed: ${reason}`); + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/i18n-sync.ts b/scripts/i18n-sync.ts new file mode 100644 index 0000000..3a4fc00 --- /dev/null +++ b/scripts/i18n-sync.ts @@ -0,0 +1,64 @@ +/** + * Adds any key present in en.json but missing from another locale file, using the English value + * as a placeholder (untranslated but functionally present — i18next falls back to showing the key + * path itself otherwise, which is worse than an English string). Never removes or overwrites an + * existing translated value. Run with `npx tsx scripts/i18n-sync.ts`. + */ +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type LocaleTree = { [key: string]: string | LocaleTree }; + +const LOCALES_DIR = join(__dirname, '..', 'src', 'i18n', 'locales'); + +function mergeMissing( + base: LocaleTree, + target: LocaleTree, +): { merged: LocaleTree; addedCount: number } { + let addedCount = 0; + const merged: LocaleTree = { ...target }; + + for (const [key, baseValue] of Object.entries(base)) { + const targetValue = target[key]; + if (targetValue === undefined) { + merged[key] = baseValue; + addedCount += typeof baseValue === 'object' ? countLeaves(baseValue) : 1; + } else if (typeof baseValue === 'object' && typeof targetValue === 'object') { + const result = mergeMissing(baseValue, targetValue); + merged[key] = result.merged; + addedCount += result.addedCount; + } + } + + return { merged, addedCount }; +} + +function countLeaves(tree: LocaleTree): number { + return Object.values(tree).reduce<number>( + (sum, value) => sum + (typeof value === 'object' ? countLeaves(value) : 1), + 0, + ); +} + +function main() { + const enPath = join(LOCALES_DIR, 'en.json'); + const en = JSON.parse(readFileSync(enPath, 'utf-8')) as LocaleTree; + + const files = readdirSync(LOCALES_DIR).filter( + (name) => name.endsWith('.json') && name !== 'en.json', + ); + + for (const file of files) { + const filePath = join(LOCALES_DIR, file); + const existing = JSON.parse(readFileSync(filePath, 'utf-8')) as LocaleTree; + const { merged, addedCount } = mergeMissing(en, existing); + + if (addedCount > 0) { + writeFileSync(filePath, `${JSON.stringify(merged, null, 2)}\n`); + // eslint-disable-next-line no-console -- CLI script output, not app runtime code. + console.log(`${file}: added ${addedCount} missing key(s)`); + } + } +} + +main(); diff --git a/src/api/auth-endpoints.ts b/src/api/auth-endpoints.ts new file mode 100644 index 0000000..1030fae --- /dev/null +++ b/src/api/auth-endpoints.ts @@ -0,0 +1,148 @@ +import { env } from '@/config/env'; + +import { setAuthToken } from './auth-token'; + +/** + * Hand-rolled fetch calls against the backend's better-auth REST routes (`/api/auth/[...all]`). + * These are NOT part of the oRPC router the generated schema.ts covers, so they can't go through + * the typed apiClient — better-auth ships its own REST surface with its own error shape + * (`{ message, code }`, no `status`/`defined`, unlike oRPC's `{ code, status, message, defined }`). + */ +const AUTH_BASE_URL = `${env.apiBaseUrl}/api/auth`; + +export interface SessionUser { + id: string; + email: string; + name: string | null; + mustChangePassword: boolean; +} + +export class AuthApiError extends Error { + readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = 'AuthApiError'; + this.code = code; + } +} + +interface BetterAuthErrorBody { + message?: string; + code?: string; +} + +async function throwIfError(response: Response): Promise<void> { + if (response.ok) return; + + let body: BetterAuthErrorBody = {}; + try { + body = (await response.json()) as BetterAuthErrorBody; + } catch { + // Non-JSON error body — fall back to statusText below. + } + throw new AuthApiError(body.message ?? response.statusText, body.code); +} + +/** + * Signs in with email/password. Captures the `set-auth-token` response header — the bearer() + * plugin mirrors the session cookie there for non-browser clients — and persists it via + * setAuthToken before returning the session user. + */ +export async function signInWithEmail(email: string, password: string): Promise<SessionUser> { + const response = await fetch(`${AUTH_BASE_URL}/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + await throwIfError(response); + + const token = response.headers.get('set-auth-token'); + if (!token) { + throw new AuthApiError('Sign-in succeeded but no auth token was returned.'); + } + await setAuthToken(token); + + const body = (await response.json()) as { user: SessionUser }; + return body.user; +} + +/** Fetches the current session using the stored bearer token (attached by the caller via + * Authorization header) — returns null when there is no active session (never throws for that). */ +export async function getSession(token: string): Promise<SessionUser | null> { + const response = await fetch(`${AUTH_BASE_URL}/get-session`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + return null; + } + + const body = (await response.json()) as { user: SessionUser } | null; + return body?.user ?? null; +} + +export async function requestPasswordReset(email: string): Promise<void> { + const response = await fetch(`${AUTH_BASE_URL}/forget-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, redirectTo: '/' }), + }); + await throwIfError(response); +} + +/** Changes the current user's password. Used for the forced mustChangePassword flow — the + * backend's session-cookie rotation (nextCookies + revokeOtherSessions) doesn't apply to bearer + * clients, so we re-fetch the session afterwards rather than relying on an updated cookie. */ +export async function changePassword( + token: string, + currentPassword: string, + newPassword: string, +): Promise<void> { + const response = await fetch(`${AUTH_BASE_URL}/change-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ currentPassword, newPassword, revokeOtherSessions: true }), + }); + await throwIfError(response); +} + +export async function signOut(token: string): Promise<void> { + const response = await fetch(`${AUTH_BASE_URL}/sign-out`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + // Best-effort: even if the server call fails, the caller clears local token/state. + if (!response.ok) { + return; + } +} + +export type SocialProvider = 'google' | 'facebook'; + +/** The app's custom URL scheme (app.config.ts `scheme: 'chatbotxmobileapp'`) that the OAuth + * broker relays back to once the provider flow completes. */ +export const SOCIAL_CALLBACK_URL = 'chatbotxmobileapp://auth-callback'; + +/** + * Starts a social sign-in flow. Shape per better-auth's `/sign-in/social` route: request + * `{ provider, callbackURL }`, response `{ redirect: boolean; url: string }`. + * + * The returned `url` sends the user through the provider, then the backend's fixed OAuth + * "broker" host relays back to `callbackURL`. `chatbotxmobileapp://` must be in the backend's + * better-auth `trustedOrigins`. + */ +export async function startSocialSignIn(provider: SocialProvider): Promise<string> { + const response = await fetch(`${AUTH_BASE_URL}/sign-in/social`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, callbackURL: SOCIAL_CALLBACK_URL }), + }); + + await throwIfError(response); + + const body = (await response.json()) as { redirect: boolean; url: string }; + return body.url; +} diff --git a/src/api/auth-token.ts b/src/api/auth-token.ts new file mode 100644 index 0000000..631fbe5 --- /dev/null +++ b/src/api/auth-token.ts @@ -0,0 +1,24 @@ +import * as SecureStore from 'expo-secure-store'; + +/** + * Auth token storage, backed by expo-secure-store (Keychain on iOS, Keystore-backed + * EncryptedSharedPreferences on Android). + * + * This module only handles *storage* of a bearer token. The actual auth flow (sign-in, token + * exchange, refresh) is a later phase, once the backend's bearer-auth endpoints are live. + * `getAuthToken` currently always resolves to whatever was last persisted (or null), with no + * refresh logic. + */ +const AUTH_TOKEN_KEY = 'chatbotx.authToken'; + +export async function getAuthToken(): Promise<string | null> { + return SecureStore.getItemAsync(AUTH_TOKEN_KEY); +} + +export async function setAuthToken(token: string): Promise<void> { + await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token); +} + +export async function clearAuthToken(): Promise<void> { + await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); +} diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..ffe859d --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,120 @@ +import * as SecureStore from 'expo-secure-store'; +import type { Middleware } from 'openapi-fetch'; + +import { apiClient } from '@/api/client'; + +// Jest hoists `jest.mock` calls above these imports at compile time regardless of source +// position, so this ordering (imports first, mocks after) is purely to satisfy `import/first` — +// it does not change which runs first. +jest.mock('expo-constants', () => ({ + __esModule: true, + default: { + expoConfig: { + extra: { apiBaseUrl: 'http://localhost:3123', wsUrl: 'ws://localhost:3123' }, + }, + }, +})); +jest.mock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + +/** + * Stand-in for React Native's second `Response` realm (see the plan this test accompanies): + * duck-types a Response but is never `instanceof` the global `Response` openapi-fetch checks + * against, which is exactly the mismatch that produced + * "onResponse: must return new Response() when modifying the response". + */ +class ForeignResponse { + constructor( + private readonly bodyText: string, + private readonly init: ResponseInit = {}, + ) {} + get status() { + return this.init.status ?? 200; + } + get statusText() { + return this.init.statusText ?? ''; + } + get headers() { + return new Headers(this.init.headers); + } + async text() { + return this.bodyText; + } + async json() { + return JSON.parse(this.bodyText); + } +} + +describe('apiClient', () => { + let fetchMock: jest.Mock; + let serverResponse: Response; + + beforeEach(() => { + serverResponse = new Response(JSON.stringify({ id: '123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + fetchMock = jest.fn().mockResolvedValue(serverResponse); + }); + + it('does not throw the RN dual-Response error, because no onResponse middleware is registered', async () => { + jest.mocked(SecureStore.getItemAsync).mockResolvedValueOnce(null); + + const result = await apiClient.GET('/analytics/broadcasts/{broadcastId}/stats', { + params: { path: { broadcastId: '1' }, query: { workspaceId: 'w1' } }, + fetch: fetchMock, + }); + + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ id: '123' }); + }); + + it('regression: a rewrapping onResponse middleware reproduces the exact RN dual-Response error', async () => { + // Proves the mechanism this suite guards against: only a middleware that reconstructs the + // response (as the deleted bigNumberSafeParsingMiddleware did) can trigger openapi-fetch's + // `instanceof Response` check. If such a middleware is ever reintroduced against a + // foreign-realm Response class, it fails exactly like the original bug. + const rewrapMiddleware: Middleware = { + async onResponse({ response }) { + const raw = await response.text(); + return new ForeignResponse(raw, { status: response.status }) as unknown as Response; + }, + }; + apiClient.use(rewrapMiddleware); + jest.mocked(SecureStore.getItemAsync).mockResolvedValueOnce(null); + + try { + await expect( + apiClient.GET('/analytics/broadcasts/{broadcastId}/stats', { + params: { path: { broadcastId: '1' }, query: { workspaceId: 'w1' } }, + fetch: fetchMock, + }), + ).rejects.toThrow('onResponse: must return new Response() when modifying the response'); + } finally { + apiClient.eject(rewrapMiddleware); + } + }); + + it('sets Authorization: Bearer <token> when getAuthToken resolves a token', async () => { + jest.mocked(SecureStore.getItemAsync).mockResolvedValueOnce('test-token'); + + await apiClient.GET('/analytics/broadcasts/{broadcastId}/stats', { + params: { path: { broadcastId: '1' }, query: { workspaceId: 'w1' } }, + fetch: fetchMock, + }); + + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.headers.get('Authorization')).toBe('Bearer test-token'); + }); + + it('leaves the Authorization header unset when getAuthToken resolves null', async () => { + jest.mocked(SecureStore.getItemAsync).mockResolvedValueOnce(null); + + await apiClient.GET('/analytics/broadcasts/{broadcastId}/stats', { + params: { path: { broadcastId: '1' }, query: { workspaceId: 'w1' } }, + fetch: fetchMock, + }); + + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.headers.has('Authorization')).toBe(false); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..00055b3 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,33 @@ +import createClient, { type Middleware } from 'openapi-fetch'; + +import { getAuthToken } from '@/api/auth-token'; +import { env } from '@/config/env'; + +import type { paths } from './generated/schema'; + +/** + * Attaches `Authorization: Bearer <token>` to every outgoing request when a token is stored. + */ +const authMiddleware: Middleware = { + async onRequest({ request }) { + const token = await getAuthToken(); + + if (token) { + request.headers.set('Authorization', `Bearer ${token}`); + } + + return request; + }, +}; + +/** + * Typed fetch client for the ChatbotX REST API, generated from its OpenAPI spec. + * Run `pnpm generate:api` to (re)generate src/api/generated/schema.ts against a running backend. + * + * All oRPC routes in the generated spec are served under `/api` (auth and the hand-rolled + * multipart upload route are separate surfaces under plain `/api` — see auth-endpoints.ts and + * send-message-multipart.ts). + */ +export const apiClient = createClient<paths>({ baseUrl: `${env.apiBaseUrl}/api` }); + +apiClient.use(authMiddleware); diff --git a/src/api/errors.test.ts b/src/api/errors.test.ts new file mode 100644 index 0000000..6a02d81 --- /dev/null +++ b/src/api/errors.test.ts @@ -0,0 +1,133 @@ +import { ApiError, normalizeApiError } from '@/api/errors'; + +describe('normalizeApiError', () => { + it('returns mustChangePassword for FORBIDDEN with the change-password message', () => { + const error = new ApiError({ code: 'FORBIDDEN', message: 'Password change required' }); + expect(normalizeApiError(error)).toEqual({ kind: 'mustChangePassword' }); + }); + + it('returns unauthorized for UNAUTHORIZED code', () => { + const error = new ApiError({ code: 'UNAUTHORIZED' }); + expect(normalizeApiError(error)).toEqual({ kind: 'unauthorized' }); + }); + + it('returns unauthorized for status 401', () => { + const error = new ApiError({ status: 401 }); + expect(normalizeApiError(error)).toEqual({ kind: 'unauthorized' }); + }); + + it('classifies workspaceBlocked as mac when message mentions contact limit', () => { + const error = new ApiError({ code: 'workspaceBlocked', message: 'Hit the contact limit' }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'mac', + message: 'Hit the contact limit', + }); + }); + + it('classifies workspaceBlocked as trialExpired when message mentions trial/expired', () => { + const error = new ApiError({ status: 402, message: 'Your trial has expired' }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'trialExpired', + message: 'Your trial has expired', + }); + }); + + it('classifies workspaceBlocked as unknown when message matches neither pattern', () => { + const error = new ApiError({ status: 402, message: 'Something else' }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'unknown', + message: 'Something else', + }); + }); + + it('prefers a machine-readable data.reason over substring matching', () => { + const error = new ApiError({ + status: 402, + message: 'Some unrelated free-form copy that mentions nothing recognizable', + data: { reason: 'mac' }, + }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'mac', + message: 'Some unrelated free-form copy that mentions nothing recognizable', + }); + }); + + it('uses data.reason=trialExpired even when the message would substring-match mac', () => { + const error = new ApiError({ + status: 402, + message: 'reached the contact limit', + data: { reason: 'trialExpired' }, + }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'trialExpired', + message: 'reached the contact limit', + }); + }); + + it('falls back to substring matching when data.reason is an unrecognized value', () => { + const error = new ApiError({ + status: 402, + message: 'Your trial has expired', + data: { reason: 'somethingUnexpected' }, + }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'trialExpired', + message: 'Your trial has expired', + }); + }); + + it('falls back to substring matching when data is absent', () => { + const error = new ApiError({ status: 402, message: 'Hit the contact limit' }); + expect(normalizeApiError(error)).toEqual({ + kind: 'workspaceBlocked', + reason: 'mac', + message: 'Hit the contact limit', + }); + }); + + it('defaults workspaceBlocked message when body has none', () => { + const error = new ApiError({ status: 402 }); + const result = normalizeApiError(error); + expect(result.kind).toBe('workspaceBlocked'); + if (result.kind === 'workspaceBlocked') { + expect(result.message).toBe('This workspace is currently unavailable.'); + } + }); + + it('returns notFound for NOT_FOUND code', () => { + const error = new ApiError({ code: 'NOT_FOUND' }); + expect(normalizeApiError(error)).toEqual({ kind: 'notFound' }); + }); + + it('returns notFound for status 404', () => { + const error = new ApiError({ status: 404 }); + expect(normalizeApiError(error)).toEqual({ kind: 'notFound' }); + }); + + it('returns unknown with body message for unrecognized ApiError', () => { + const error = new ApiError({ code: 'SOMETHING_ELSE', message: 'weird failure' }); + expect(normalizeApiError(error)).toEqual({ kind: 'unknown', message: 'weird failure' }); + }); + + it('returns unknown with default message when ApiError body has no message', () => { + const error = new ApiError({ code: 'SOMETHING_ELSE' }); + expect(normalizeApiError(error)).toEqual({ kind: 'unknown', message: 'Unknown error' }); + }); + + it('returns unknown with the Error message for a non-ApiError Error', () => { + expect(normalizeApiError(new Error('boom'))).toEqual({ kind: 'unknown', message: 'boom' }); + }); + + it('returns a generic unknown for a non-Error thrown value', () => { + expect(normalizeApiError('not an error')).toEqual({ + kind: 'unknown', + message: 'Unknown error', + }); + }); +}); diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..446cbcd --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,90 @@ +/** + * Normalizes the oRPC error wire shape (`{ code, status, message, defined }` — verified against + * the running backend) into a discriminated union the rest of the app can switch on. `openapi-fetch` doesn't throw on HTTP errors — it resolves `{ data, error }` — so + * every mutation/query hook's `queryFn`/`mutationFn` must check `error` and + * `throw new ApiError(error)` for react-query's QueryCache/MutationCache `onError` (wired in + * lib/query-client.ts) to see it. Business-rule errors (ChatbotXException) surface their own + * `code`/`status`, e.g. `workspaceBlocked` at 402. + */ +export interface ApiErrorBody { + code?: string; + status?: number; + message?: string; + defined?: boolean; + /** + * Machine-readable payload some business-rule errors may attach. `data.reason` is a forward- + * compatible hook for the 402 `workspaceBlocked` error: the backend does not send it today (as + * of this writing `classifyWorkspaceBlockedReason` below is the only source of truth, matched + * by English substring against `message`), but once it does, this field is read first and the + * substring match becomes pure fallback. No schema for `data` exists + * yet in `src/api/generated/schema.ts`, so this stays loosely typed until the backend ships it. + */ + data?: { reason?: string }; +} + +export type NormalizedApiError = + | { kind: 'unauthorized' } + | { kind: 'mustChangePassword' } + | { kind: 'workspaceBlocked'; reason: 'mac' | 'trialExpired' | 'unknown'; message: string } + | { kind: 'notFound' } + | { kind: 'unknown'; message: string }; + +const MUST_CHANGE_PASSWORD_MESSAGE = 'Password change required'; + +/** Thrown by hooks when `openapi-fetch` resolves an `error` body, so react-query's error + * channel (retry, onError, `error` from useQuery) sees API failures. */ +export class ApiError extends Error { + readonly body: ApiErrorBody; + + constructor(body: ApiErrorBody) { + super(body.message ?? 'API error'); + this.name = 'ApiError'; + this.body = body; + } +} + +/** Machine-readable reason codes the backend may eventually send in `data.reason`. Any other + * string value falls through to the substring-matching fallback below rather than being trusted + * blindly, in case the backend ships a value this app doesn't recognize yet. */ +const KNOWN_REASON_CODES = new Set(['mac', 'trialExpired']); + +function classifyWorkspaceBlockedReason( + message: string, + dataReason: string | undefined, +): 'mac' | 'trialExpired' | 'unknown' { + if (dataReason && KNOWN_REASON_CODES.has(dataReason)) { + return dataReason as 'mac' | 'trialExpired'; + } + + const lowered = message.toLowerCase(); + if (lowered.includes('contact limit')) return 'mac'; + if (lowered.includes('trial') || lowered.includes('expired')) return 'trialExpired'; + return 'unknown'; +} + +export function normalizeApiError(error: unknown): NormalizedApiError { + const body: ApiErrorBody | null = error instanceof ApiError ? error.body : null; + + if (!body) { + return { kind: 'unknown', message: error instanceof Error ? error.message : 'Unknown error' }; + } + + if (body.code === 'FORBIDDEN' && body.message === MUST_CHANGE_PASSWORD_MESSAGE) { + return { kind: 'mustChangePassword' }; + } + if (body.code === 'UNAUTHORIZED' || body.status === 401) { + return { kind: 'unauthorized' }; + } + if (body.code === 'workspaceBlocked' || body.status === 402) { + const message = body.message ?? 'This workspace is currently unavailable.'; + return { + kind: 'workspaceBlocked', + reason: classifyWorkspaceBlockedReason(message, body.data?.reason), + message, + }; + } + if (body.code === 'NOT_FOUND' || body.status === 404) { + return { kind: 'notFound' }; + } + return { kind: 'unknown', message: body.message ?? 'Unknown error' }; +} diff --git a/src/api/generated/README.md b/src/api/generated/README.md new file mode 100644 index 0000000..983817a --- /dev/null +++ b/src/api/generated/README.md @@ -0,0 +1,8 @@ +Generated by `pnpm generate:api` (scripts/generate-api-client.ts) from the ChatbotX builder's +OpenAPI spec (`{API_BASE_URL}/api/spec.json`, the full oRPC router spec — override with +`API_SPEC_PATH=/api/public-spec.json` for the workspace-token-only surface instead). Do not +hand-edit files in this directory — they are overwritten on every regeneration. + +`schema.ts` is committed so the app builds without a live backend available. Re-run +`pnpm generate:api` against a running ChatbotX builder instance to refresh it after backend API +changes. diff --git a/src/api/generated/schema.ts b/src/api/generated/schema.ts new file mode 100644 index 0000000..3ca0757 --- /dev/null +++ b/src/api/generated/schema.ts @@ -0,0 +1,20657 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/analytics/broadcasts/{broadcastId}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get broadcast stats */ + get: operations["analyticsRoutes.getBroadcastStatsAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/contact-counts-per-day": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contact counts per day */ + get: operations["analyticsRoutes.contactCountsPerDayAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/new-contact-counts-per-day": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get new contact counts per day */ + get: operations["analyticsRoutes.newContactCountsPerDayAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/blocked-contacts-per-day": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get blocked contacts per day */ + get: operations["analyticsRoutes.blockedContactsPerDayAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/blocked-contacts-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get blocked contacts count */ + get: operations["analyticsRoutes.blockedContactsCountAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/new-contacts-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get new contact counts */ + get: operations["analyticsRoutes.newContactsCountAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/contacts-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contacts count */ + get: operations["analyticsRoutes.contactsCountAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/active-contacts-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get active contacts count */ + get: operations["analyticsRoutes.activeContactsCountAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/contacts-by-dimension": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contacts by dimension */ + get: operations["analyticsRoutes.contactsByDimensionAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/messages-by-admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get messages sent by admin */ + get: operations["analyticsRoutes.messagesByAdminAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/human-agent-stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get human agent statistics */ + get: operations["analyticsRoutes.humanAgentStatsAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/conversation-handoffs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get conversation handoffs */ + get: operations["analyticsRoutes.conversationHandoffsAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/conversation-followups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get conversation follow-ups */ + get: operations["analyticsRoutes.conversationFollowUpsAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/conversation-archived": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get archived conversations */ + get: operations["analyticsRoutes.conversationArchivedAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/conversation-assigned": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get assigned conversations */ + get: operations["analyticsRoutes.conversationAssignedAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/conversation-assigned-by-admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get assigned conversations by admin */ + get: operations["analyticsRoutes.conversationAssignedByAdminAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/unique-conversations-by-admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get unique conversations by admin */ + get: operations["analyticsRoutes.uniqueConversationsByAdminAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/bot-messages-by-result": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get bot messages by result */ + get: operations["analyticsRoutes.botMessagesByResultAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/bot-messages-with-response": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get bot messages with response */ + get: operations["analyticsRoutes.botMessagesWithResponseAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/bot-messages-no-response": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get bot messages with no response */ + get: operations["analyticsRoutes.botMessagesNoResponseAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/bot-messages-ai-providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get bot messages AI providers */ + get: operations["analyticsRoutes.botMessagesAIProvidersAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/messages-by-sender": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get messages by sender */ + get: operations["analyticsRoutes.messagesBySenderAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/sequences/{sequenceId}/steps/{stepId}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sequence step stats */ + get: operations["analyticsRoutes.getSequenceStepStatsAnalyticsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/flows/{flowId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get analytics sessions */ + get: operations["analyticsRoutes.getFlowAnalytics"]; + put?: never; + post?: never; + /** Delete analytics sessions */ + delete: operations["analyticsRoutes.resetFlowAnalytics"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/mac/active-count/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current period MAC count for a workspace */ + get: operations["analyticsRoutes.macActiveContactCountByWorkspaceAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/ref-links-stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get ref link stats */ + get: operations["analyticsRoutes.refLinkStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/ref-links-contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get ref link contacts */ + get: operations["analyticsRoutes.refLinkContacts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/magic-links-stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get magic link stats */ + get: operations["analyticsRoutes.magicLinkStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/analytics/magic-links-contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get magic link contacts */ + get: operations["analyticsRoutes.magicLinkContacts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ads/conversion-rules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Ads conversion rules */ + get: operations["adsAPI.listRules"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/appointment-calendars/for-flow": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List appointment calendars for flow */ + get: operations["appointmentCalendarsAPI.listAppointmentCalendarsForFlowAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/appointments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List appointments for a contact */ + get: operations["appointmentsAPI.listContactAppointmentsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ai-mcp-servers/validate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate an MCP server */ + post: operations["aiMcpServerAPIs.validateAIMcpServerAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ai-mcp-servers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AI MCP servers */ + get: operations["aiMcpServerAPIs.listAIMcpServersAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ai-agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AI agents */ + get: operations["aiAgentsAPI.listAIAgentsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ai-agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AI agents */ + get: operations["aiAgentsAPI.listAIAgentsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/broadcasts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all broadcasts */ + get: operations["broadcastAPIs.listBroadcastsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/broadcasts/{idOrName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get broadcast by id or name */ + get: operations["broadcastAPIs.getBroadcastWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/broadcasts/{idOrName}/audience": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get broadcast audience */ + get: operations["broadcastAPIs.getBroadcastAudienceWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/broadcasts/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List broadcast options */ + get: operations["broadcastAPIs.privateListBroadcastOptionsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/broadcasts/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Get batch broadcast stats */ + post: operations["broadcastAPIs.privateGetBatchBroadcastStatsAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/broadcasts/{broadcastId}/template-detail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get broadcast template detail */ + get: operations["broadcastAPIs.privateGetBroadcastTemplateDetailAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/broadcasts/{broadcastId}/contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List broadcast contacts by event type */ + get: operations["broadcastAPIs.privateListBroadcastContactsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List conversations by cursor pagination */ + post: operations["conversationsAPI.listConversationsAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List conversations by cursor pagination using POST request */ + post: operations["conversationsAPI.listConversationsByPOSTAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Find conversation by conversation id */ + get: operations["conversationsAPI.findConversationAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/post-details": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Facebook post details for a comment conversation */ + get: operations["conversationsAPI.getPostDetailsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/assign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Assign or unassign conversations to a user or inbox team */ + post: operations["conversationsAPI.assignConversationsAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive conversations */ + post: operations["conversationsAPI.archiveConversationsAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unarchive conversations */ + post: operations["conversationsAPI.unarchiveConversationsAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/enable-bot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Re-enable the bot for conversations */ + post: operations["conversationsAPI.enableBotAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/disable-bot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable the bot for conversations (hand off to a human) */ + post: operations["conversationsAPI.disableBotAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{id}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark a conversation as read */ + post: operations["conversationsAPI.readConversationAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{id}/unread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark a conversation as unread */ + post: operations["conversationsAPI.unreadConversationAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{id}/follow": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Follow a conversation */ + post: operations["conversationsAPI.followConversationAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{id}/unfollow": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unfollow a conversation */ + post: operations["conversationsAPI.unfollowConversationAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/conversations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List conversations */ + get: operations["conversationsAPI.listConversationsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List coupon topics */ + get: operations["couponsAPI.listCouponTopicsAPI"]; + put?: never; + /** Create coupon topic */ + post: operations["couponsAPI.createCouponTopicAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/topics/{topicId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete coupon topic */ + delete: operations["couponsAPI.deleteCouponTopicAPI"]; + options?: never; + head?: never; + /** Update coupon topic */ + patch: operations["couponsAPI.updateCouponTopicAPI"]; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/topics/{topicId}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive coupon topic */ + post: operations["couponsAPI.archiveCouponTopicAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/topics/{topicId}/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unarchive coupon topic */ + post: operations["couponsAPI.unarchiveCouponTopicAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List coupons */ + get: operations["couponsAPI.listCouponsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/topic-options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List coupon topic options */ + get: operations["couponsAPI.listCouponTopicOptionsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/export-count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Count coupon export rows */ + get: operations["couponsAPI.countCouponExportAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/coupons/export-files/{fileId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get coupon export file status */ + get: operations["couponsAPI.getCouponExportFileAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/coupons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List issued coupons for a contact */ + get: operations["couponsAPI.listContactCouponsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/me/device-tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Register a push notification device token for the current user */ + put: operations["deviceTokensAPI.registerDeviceTokenAPI"]; + post?: never; + /** Unregister a push notification device token */ + delete: operations["deviceTokensAPI.unregisterDeviceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/dynamic-images/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a dynamic image */ + get: operations["dynamicImagesAPI.getDynamicImageAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/email-topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List email topics */ + get: operations["emailTopicsAPI.privateListWorkspaceEmailTopicsAPI"]; + put?: never; + /** Create an email topic */ + post: operations["emailTopicsAPI.privateCreateWorkspaceEmailTopicAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/email-topics/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update email topic */ + put: operations["emailTopicsAPI.privateUpdateEmailTopicAPI"]; + post?: never; + /** Delete email topic */ + delete: operations["emailTopicsAPI.privateDeleteEmailTopicAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List tags */ + get: operations["tagsAPI.privateListWorkspaceTagsAPI"]; + put?: never; + /** Create a tag */ + post: operations["tagsAPI.privateCreateWorkspaceTagAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/tags/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update tag */ + put: operations["tagsAPI.privateUpdateTagAPI"]; + post?: never; + /** Delete tag */ + delete: operations["tagsAPI.privateDeleteTagsAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all tags */ + get: operations["tagsAPI.listTagsWorkspaceTokenAPI"]; + put?: never; + /** Create a new tag */ + post: operations["tagsAPI.createTagWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/tags/{idOrName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get tag by id or name */ + get: operations["tagsAPI.getTagWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/tags/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update tag */ + put: operations["tagsAPI.updateTagWorkspaceTokenAPI"]; + post?: never; + /** Delete tag */ + delete: operations["tagsAPI.deleteTagWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all custom fields */ + get: operations["customFieldsAPI.listCustomFieldsWorkspaceTokenAPI"]; + put?: never; + /** Create a custom field */ + post: operations["customFieldsAPI.createCustomFieldWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/custom-fields/{idOrName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get custom field by id or name */ + get: operations["customFieldsAPI.getCustomFieldWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/custom-fields/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update custom field */ + put: operations["customFieldsAPI.updateCustomFieldWorkspaceTokenAPI"]; + post?: never; + /** Delete custom field */ + delete: operations["customFieldsAPI.deleteCustomFieldWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List custom fields */ + get: operations["customFieldsAPI.privateListCustomFieldsAPI"]; + put?: never; + /** Create custom field */ + post: operations["customFieldsAPI.privateCreateCustomFieldAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/custom-fields/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update custom field */ + put: operations["customFieldsAPI.privateUpdateCustomFieldAPI"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/custom-fields/{customFieldId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete custom field */ + delete: operations["customFieldsAPI.privateDeleteCustomFieldsAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/flows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all flows */ + get: operations["flowsAPI.listFlowsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/flows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List flows */ + get: operations["flowsAPI.privateListFlowsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/flows/{flowId}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get flow stats */ + get: operations["flowsAPI.privateGetFlowStatsAPI"]; + put?: never; + post?: never; + /** Reset flow stats */ + delete: operations["flowsAPI.privateResetFlowStatsAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/flows/{flowId}/versions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List flow versions */ + get: operations["flowsAPI.privateListFlowVersionsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/flows/{flowId}/messenger-ads-json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Messenger Ads JSON */ + get: operations["flowsAPI.privateGetMessengerAdsJsonAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/flows/{flowId}/contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get flow event contacts */ + get: operations["flowsAPI.privateGetFlowContactStatsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List contacts */ + get: operations["contactsAPIs.listContactsWorkspaceTokenAPI"]; + put?: never; + /** Create a contact */ + post: operations["contactsAPIs.createContactWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contact by identifier (id:123, email:user@example.com, phone:+84...) */ + get: operations["contactsAPIs.getContactWorkspaceTokenAPI"]; + /** Update contact fields */ + put: operations["contactsAPIs.updateContactWorkspaceTokenAPI"]; + post?: never; + /** Delete a contact */ + delete: operations["contactsAPIs.deleteContactWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/find-by-custom-field": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List contacts by custom field + * @description Find contacts by custom field value. It will return maximum 100 contacts. The results are sorted by the last custom field value update for a contact. + */ + get: operations["contactsAPIs.filterContactsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all tags added to this contact */ + get: operations["contactsAPIs.listContactTagsWorkspaceTokenAPI"]; + put?: never; + /** Add tags to the contact */ + post: operations["contactsAPIs.addContactTagsWorkspaceTokenAPI"]; + /** Remove tags from the contact */ + delete: operations["contactsAPIs.removeContactTagsWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all custom fields from a contact */ + get: operations["contactsAPIs.listContactCustomFieldsWorkspaceTokenAPI"]; + /** Set multiple custom field values for a contact */ + put: operations["contactsAPIs.setContactCustomFieldsWorkspaceTokenAPI"]; + post?: never; + /** Clear all custom fields from a contact */ + delete: operations["contactsAPIs.clearContactCustomFieldsWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/custom-fields/{customFieldId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contact custom field value */ + get: operations["contactsAPIs.getContactCustomFieldWorkspaceTokenAPI"]; + put?: never; + /** Set contact custom field value */ + post: operations["contactsAPIs.setContactCustomFieldWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/custom-fields/{idOrName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete contact custom field by id or name */ + delete: operations["contactsAPIs.clearContactCustomFieldWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/block": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Block a contact */ + post: operations["contactsAPIs.blockContactWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/unblock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unblock a contact */ + post: operations["contactsAPIs.unblockContactWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List messages for contact */ + get: operations["contactsAPIs.listContactMessagesWorkspaceTokenAPI"]; + put?: never; + /** Send message to contact */ + post: operations["contactsAPIs.sendMessageWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/messages/{messageId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a message by ID for a contact */ + get: operations["contactsAPIs.getContactMessageWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/auto-replies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Trigger auto reply for contact */ + post: operations["contactsAPIs.triggerAutoReplyWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/flows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send flow to contact */ + post: operations["contactsAPIs.sendContactFlowWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Import contacts from a file */ + post: operations["contactsAPIs.importContactsWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contacts/{identifier}/upsert": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upsert a contact by identifier */ + post: operations["contactsAPIs.upsertContactWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contact */ + get: operations["contactsAPIs.getContactAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List contacts */ + get: operations["contactsAPIs.listContactsAuthenticatedAPI"]; + put?: never; + /** Create a contact */ + post: operations["contactsAPIs.createContactAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List contacts using POST request */ + post: operations["contactsAPIs.listContactsByPOSTAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Count contacts */ + get: operations["contactsAPIs.countContactsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/inboxes/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Count contact inboxes */ + get: operations["contactsAPIs.countContactInboxesAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/inboxes/audience-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List audience contact inboxes preview */ + post: operations["contactsAPIs.listContactInboxesAudiencePreviewAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/export-files/{fileId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get contact export file status */ + get: operations["contactsAPIs.getExportFileAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List contact tags */ + get: operations["contactsAPIs.listContactTagsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add tags to contact */ + post: operations["contactsAPIs.addContactTagAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/tags/{tagId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove tag from contact */ + delete: operations["contactsAPIs.removeContactTagAuthenticatedAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List contact custom fields */ + get: operations["contactsAPIs.listContactFieldsAuthenticatedAPI"]; + put?: never; + /** Set contact custom field value */ + post: operations["contactsAPIs.addContactFieldAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/contacts/{contactId}/fields/{customFieldId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete contact custom field */ + delete: operations["contactsAPIs.deleteContactFieldAuthenticatedAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/bot-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all bot fields */ + get: operations["botFieldAPIs.listBotFieldsWorkspaceTokenAPI"]; + /** Set multiple bot field values */ + put: operations["botFieldAPIs.setBotFieldsWorkspaceTokenAPI"]; + /** Create a new bot field */ + post: operations["botFieldAPIs.createBotFieldWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/bot-fields/{idOrName}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get bot field by id or name */ + get: operations["botFieldAPIs.getBotFieldWorkspaceTokenAPI"]; + /** Set bot field value by id or name */ + put: operations["botFieldAPIs.setBotFieldWorkspaceTokenAPI"]; + post?: never; + /** Unset the value of the bot field by id or name */ + delete: operations["botFieldAPIs.deleteBotFieldsWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/bot-fields/bulk-update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Bulk update bot field values by id or name */ + put: operations["botFieldAPIs.bulkUpdateBotFieldsWorkspaceTokenAPI"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/active-campaign/lists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ActiveCampaign lists */ + get: operations["integrationActiveCampaignAPI.listLists"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/active-campaign/automations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ActiveCampaign automations */ + get: operations["integrationActiveCampaignAPI.listAutomations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/active-campaign/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ActiveCampaign tags */ + get: operations["integrationActiveCampaignAPI.listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/active-campaign/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ActiveCampaign custom fields */ + get: operations["integrationActiveCampaignAPI.listCustomFields"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/facebook-ads/ad-accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Facebook ad accounts */ + get: operations["integrationFacebookAdsAPI.listAdAccounts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/facebook-ads/custom-audiences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List custom audiences of a Facebook ad account */ + get: operations["integrationFacebookAdsAPI.listCustomAudiences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/facebook-ads/ad-insights": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Facebook ad insights */ + get: operations["integrationFacebookAdsAPI.getAdInsights"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/drip/accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Drip accounts */ + get: operations["integrationDripAPI.listAccounts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/drip/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Drip tags */ + get: operations["integrationDripAPI.listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/drip/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Drip custom fields */ + get: operations["integrationDripAPI.listCustomFields"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/get-response/campaigns": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List GetResponse campaigns */ + get: operations["integrationGetResponseAPI.listCampaigns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/get-response/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List GetResponse tags */ + get: operations["integrationGetResponseAPI.listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/integrations/instagram/{integrationId}/coexist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable or disable native Instagram coexist sync */ + post: operations["integrationInstagramAPIs.setCoexistInstagramAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/klaviyo/lists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Klaviyo lists */ + get: operations["integrationKlaviyoAPI.listLists"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/mailchimp/audiences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Mailchimp audiences */ + get: operations["integrationMailchimpAPI.listAudiences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/mailchimp/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Mailchimp tags */ + get: operations["integrationMailchimpAPI.listTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/mailchimp/merge-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Mailchimp merge fields */ + get: operations["integrationMailchimpAPI.listMergeFields"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/mailer-lite/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List MailerLite groups */ + get: operations["integrationMailerLiteAPI.listGroups"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/mailer-lite/fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List MailerLite fields */ + get: operations["integrationMailerLiteAPI.listFields"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/integrations/messenger/{integrationId}/coexist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable or disable Messenger coexist sync */ + post: operations["integrationMessengerAPIs.setCoexistMessengerAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/moosend/mailing-lists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Moosend mailing lists */ + get: operations["integrationMoosendAPI.listMailingLists"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/integrations/smtp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List SMTP integrations */ + get: operations["integrationSmtpAPI.listIntegrationSmtps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/sendgrid/lists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List SendGrid lists */ + get: operations["integrationSendGridAPI.listLists"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/sendgrid/custom-fields": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List SendGrid custom fields */ + get: operations["integrationSendGridAPI.listCustomFields"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/integrations/whatsapp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List whatsapp integration */ + get: operations["integrationWhatsappAPIs.listIntegrationWhatsappInternalAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/integrations/whatsapp/{integrationId}/coexist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable or disable WhatsApp coexist sync */ + post: operations["integrationWhatsappAPIs.setCoexistWhatsappAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/whatsapp-message-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List whatsapp message templates */ + get: operations["whatsappMessageTemplateAPIs.listWhatsappMessageTemplatesInternalAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/template-messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List template messages */ + get: operations["whatsappMessageTemplateAPIs.listTemplateMessagesWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/whatsapp-flows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List whatsapp flows */ + get: operations["whatsappFlowAPIs.listWhatsappFlowsInternalAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/whatsapp-flows/{flowId}/screens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get whatsapp flow screens */ + get: operations["whatsappFlowAPIs.getWhatsappFlowScreensInternalAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/messenger-message-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Messenger message templates */ + get: operations["messengerMessageTemplateAPIs.listMessengerMessageTemplatesInternalAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/saved-replies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List saved replies */ + get: operations["savedRepliesAPI.listSavedRepliesAuthorizedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/saved-replies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List saved replies */ + get: operations["savedRepliesAPI.listSavedRepliesWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/fb-comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List FB Comment Automations */ + get: operations["fbCommentsAPI.listFbCommentsAPI"]; + put?: never; + /** Create FB Comment Automation */ + post: operations["fbCommentsAPI.createFbCommentAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/fb-comments/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update FB Comment Automation */ + put: operations["fbCommentsAPI.updateFbCommentAPI"]; + post?: never; + /** Delete FB Comment Automation */ + delete: operations["fbCommentsAPI.deleteFbCommentAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/fb-comments/facebook-posts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Facebook posts for FB Comment Automation */ + get: operations["fbCommentsAPI.facebookPostsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-comments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Instagram Comment Automations */ + get: operations["igCommentsAPI.listIgCommentsAPI"]; + put?: never; + /** Create Instagram Comment Automation */ + post: operations["igCommentsAPI.createIgCommentAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-comments/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Instagram Comment Automation */ + put: operations["igCommentsAPI.updateIgCommentAPI"]; + post?: never; + /** Delete Instagram Comment Automation */ + delete: operations["igCommentsAPI.deleteIgCommentAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-comments/instagram-media": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Instagram media for IG Comment Automation */ + get: operations["igCommentsAPI.instagramMediaAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-stories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Instagram Story Automations */ + get: operations["igStoriesAPI.listIgStoriesAPI"]; + put?: never; + /** Create Instagram Story Automation */ + post: operations["igStoriesAPI.createIgStoryAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-stories/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Instagram Story Automation */ + put: operations["igStoriesAPI.updateIgStoryAPI"]; + post?: never; + /** Delete Instagram Story Automation */ + delete: operations["igStoriesAPI.deleteIgStoryAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ig-stories/instagram-stories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Instagram stories for IG Story Automation */ + get: operations["igStoriesAPI.instagramStoriesAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/fb-lead-ads/pages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Messenger pages eligible for Lead Ads */ + get: operations["facebookLeadAdsAPI.listLeadAdsPagesAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/fb-lead-ads/forms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a page's lead forms */ + get: operations["facebookLeadAdsAPI.listLeadAdsFormsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/sequences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sequences */ + get: operations["sequencesAPI.listSequencesWorkspaceAuthAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/sequences/{sequenceId}/steps/{stepId}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sequence step stats */ + get: operations["sequencesAPI.privateGetSequenceStepStatsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/sequences/{sequenceId}/steps/{stepId}/contacts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sequence step contacts by event type */ + get: operations["sequencesAPI.privateListSequenceStepContactsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sequences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List sequences */ + get: operations["sequencesAPI.listSequencesWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sequences/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sequence details */ + get: operations["sequencesAPI.getSequenceWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ai-files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AI files */ + get: operations["aiFilesAPI.listAIFilesAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/inboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List inboxes */ + get: operations["inboxesAPI.listInboxesAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/inboxes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List inboxes + * @description List connected inboxes with their internal IDs. Use `id` as the `inboxId` parameter when sending messages or flows to a contact. + */ + get: operations["inboxesAPI.listInboxesWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List channels */ + get: operations["inboxesAPI.listChannelsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/spreadsheets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List spreadsheets */ + get: operations["spreadsheetsAPI.listSpreadsheetsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/worksheets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List worksheets */ + get: operations["spreadsheetsAPI.listWorksheetsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/worksheets/{spreadsheetId}/headers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List worksheet headers */ + get: operations["spreadsheetsAPI.listWorksheetHeadersAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workspace members */ + get: operations["workspaceMembersAPI.listWorkspaceMembersAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workspace members */ + get: operations["workspaceMembersAPI.listMembersWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/members/{memberId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get workspace member by id */ + get: operations["workspaceMembersAPI.getMemberWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/inbox-teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List inbox teams */ + get: operations["inboxTeamsAPI.listInboxTeamsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List teams */ + get: operations["inboxTeamsAPI.listTeamsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/folders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List folders */ + get: operations["foldersAPI.listFoldersAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List messages */ + get: operations["messagesAPI.listMessagesAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/messages/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Find message by message id */ + get: operations["messagesAPI.findMessageAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{conversationId}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Send a message on a conversation */ + post: operations["messagesAPI.createMessageAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{conversationId}/messages/{messageId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Edit a comment message */ + patch: operations["messagesAPI.editMessageAuthenticatedAPI"]; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{conversationId}/messages/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a comment message */ + delete: operations["messagesAPI.deleteMessageAuthenticatedAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/conversations/{conversationId}/messages/{messageId}/attributes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Change a message's liked/hidden attributes */ + post: operations["messagesAPI.changeMessageAttributesAuthenticatedAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/messenger-personas": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Messenger personas across the workspace's pages */ + get: operations["personasAPIs.listMessengerPersonasAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/error-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List error logs */ + get: operations["errorLogsAPI.listErrorLogsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/external-webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List external webhooks + * @description List webhooks registered by external automation platforms (e.g. Make) for this workspace. + */ + get: operations["externalWebhooksAPI.listExternalWebhooksWorkspaceTokenAPI"]; + put?: never; + /** + * Register an external webhook + * @description Registers a URL to receive events for a given event name. Idempotent — registering the same (event, url) again returns the existing registration. + */ + post: operations["externalWebhooksAPI.createExternalWebhookWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/external-webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Unregister an external webhook */ + delete: operations["externalWebhooksAPI.deleteExternalWebhookWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get workspace */ + get: operations["workspacesAPI.getWorkspaceWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/me/workspaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workspaces the current user is a member of */ + get: operations["workspacesAPI.listMyWorkspacesAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ai-functions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AI functions */ + get: operations["aiFunctionsAPI.listAIFunctionsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/platform-credentials/giphy/api-key": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Giphy API key */ + get: operations["platformCredentialsAPI.getGiphyApiKeyAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/products/form-options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List product form options */ + get: operations["productsAPI.listProductFormOptionsAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/products": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List products */ + get: operations["productsAPI.listProductsAuthorizedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/product-categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List product categories */ + get: operations["productCategoriesAPI.listProductCategoriesAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/questionnaires/for-flow": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List questionnaires for flow */ + get: operations["questionnairesAPI.listQuestionnairesForFlowAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ref-links/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List ref link options */ + get: operations["refLinksAPI.listRefLinkOptionsAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/ref-links/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific ref link */ + get: operations["refLinksAPI.getRefLinksAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/ref-links/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a specific ref link */ + get: operations["refLinksAPI.getRefLinkWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/keywords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List keywords (automated responses) */ + get: operations["keywordsAPI.listKeywordsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List integrations */ + get: operations["integrationsAPI.listIntegrationsWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/triggers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List triggers */ + get: operations["triggersAPI.listTriggersWorkspaceTokenAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/workspaces/{workspaceId}/user-persistent-menus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List user persistent menus */ + get: operations["userPersistentMenusAPI.listUserPersistentMenusAuthenticatedAPI"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List webhooks */ + get: operations["webhooksAPI.listWebhooksWorkspaceTokenAPI"]; + put?: never; + /** + * Register a webhook + * @description Registers a URL to receive system events (e.g. new contact, tag applied). Automation platforms (e.g. n8n) can call this to auto-attach a webhook when a workflow is activated. + */ + post: operations["webhooksAPI.createWebhookWorkspaceTokenAPI"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Unregister a webhook */ + delete: operations["webhooksAPI.deleteWebhookWorkspaceTokenAPI"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record<string, never>; +export interface components { + schemas: { + UndefinedError: { + /** @constant */ + defined: false; + code: string; + status: number; + message: string; + data?: unknown; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record<string, never>; +export interface operations { + "analyticsRoutes.getBroadcastStatsAnalyticsAPI": { + parameters: { + query: { + workspaceId: string; + }; + header?: never; + path: { + broadcastId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": number; + "message:failed": number; + }; + }; + }; + }; + }; + "analyticsRoutes.contactCountsPerDayAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + /** Format: date-time */ + date: string; + count: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.newContactCountsPerDayAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + /** Format: date-time */ + date: string; + count: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.blockedContactsPerDayAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + /** Format: date-time */ + date: string; + count: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.blockedContactsCountAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + count: number; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.newContactsCountAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + count: number; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.contactsCountAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + count: number; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.activeContactsCountAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + count: number; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.contactsByDimensionAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + dimension: "country" | "channel" | "source"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + count: number; + dimension: string; + uniqueContacts: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.messagesByAdminAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + adminId: string; + workspaceId: string; + count: number; + userEmail?: string; + userName?: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.humanAgentStatsAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + adminId: string; + workspaceId: string; + assignedConversations: number; + messagesSent: number; + uniqueContacts: number; + userEmail?: string; + userName?: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.conversationHandoffsAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + count: number; + /** @enum {string} */ + direction: "to_human" | "to_bot"; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.conversationFollowUpsAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + count: number; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.conversationArchivedAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + count: number; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.conversationAssignedAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + count: number; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.conversationAssignedByAdminAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + toAssignee: string; + count: number; + userName?: string; + userEmail?: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.uniqueConversationsByAdminAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + workspaceId: string; + toAssignee: string; + count: number; + userName?: string; + userEmail?: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.botMessagesByResultAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + granularity?: "minute" | "hour" | "day"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + aiProvider: string; + workspaceId: string; + count: number; + hasResponse: boolean; + /** @enum {string} */ + responseType: "automated_response" | "ai_agent" | "flow" | "none"; + /** @enum {string} */ + result?: "success" | "fallback"; + /** @enum {string} */ + routeType?: "flow" | "agent" | "fallback"; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.botMessagesWithResponseAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + granularity?: "minute" | "hour" | "day"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + aiProvider: string; + workspaceId: string; + count: number; + hasResponse: boolean; + /** @enum {string} */ + responseType: "automated_response" | "ai_agent" | "flow" | "none"; + /** @enum {string} */ + result?: "success" | "fallback"; + /** @enum {string} */ + routeType?: "flow" | "agent" | "fallback"; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.botMessagesNoResponseAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + granularity?: "minute" | "hour" | "day"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + aiProvider: string; + workspaceId: string; + count: number; + hasResponse: boolean; + /** @enum {string} */ + responseType: "automated_response" | "ai_agent" | "flow" | "none"; + /** @enum {string} */ + result?: "success" | "fallback"; + /** @enum {string} */ + routeType?: "flow" | "agent" | "fallback"; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.botMessagesAIProvidersAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + aiProvider: string; + count: number; + percentage: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.messagesBySenderAnalyticsAPI": { + parameters: { + query: { + from: string; + to: string; + timezone?: string; + workspaceId: string; + granularity?: "day" | "month"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + channel: string | null; + workspaceId: string; + count: number; + /** @enum {string} */ + senderType: "bot" | "human"; + /** Format: date-time */ + timestamp: string; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.getSequenceStepStatsAnalyticsAPI": { + parameters: { + query: { + workspaceId: string; + }; + header?: never; + path: { + sequenceId: string; + stepId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": number; + "message:failed": number; + }; + }; + }; + }; + }; + "analyticsRoutes.getFlowAnalytics": { + parameters: { + query: { + workspaceId: string; + }; + header?: never; + path: { + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: { + node: { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": { + clicked: number; + totalUsers: number; + }; + "message:failed": number; + }; + buttons: { + [key: string]: { + buttonId: string; + clicks: number; + }; + }; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.resetFlowAnalytics": { + parameters: { + query?: never; + header?: never; + path: { + flowId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + workspaceId: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "analyticsRoutes.macActiveContactCountByWorkspaceAPI": { + parameters: { + query: { + workspaceId: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + macCount: number; + }; + }; + }; + }; + }; + }; + "analyticsRoutes.refLinkStats": { + parameters: { + query: { + workspaceId: string; + startDate: string; + endDate: string; + linkId: string; + timezone: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + dateReport: string; + count: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.refLinkContacts": { + parameters: { + query: { + workspaceId: string; + linkId: string; + page: number; + perPage: number; + startDate?: string; + endDate?: string; + timezone?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + sourceId: string | null; + avatar: string | null; + channel: string | null; + conversationId: string; + occurredAt: string; + }[]; + total: number; + page: number; + pageCount: number; + }; + }; + }; + }; + }; + "analyticsRoutes.magicLinkStats": { + parameters: { + query: { + workspaceId: string; + startDate: string; + endDate: string; + linkId: string; + timezone: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + dateReport: string; + count: number; + }[]; + }; + }; + }; + }; + }; + "analyticsRoutes.magicLinkContacts": { + parameters: { + query: { + workspaceId: string; + linkId: string; + page: number; + perPage: number; + startDate?: string; + endDate?: string; + timezone?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + sourceId: string | null; + avatar: string | null; + channel: string | null; + conversationId: string; + occurredAt: string; + }[]; + total: number; + page: number; + pageCount: number; + }; + }; + }; + }; + }; + "adsAPI.listRules": { + parameters: { + query?: { + channel?: "whatsapp" | "facebook"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + /** @enum {string} */ + channel: "whatsapp" | "facebook"; + integrationWhatsappId: string | null; + integrationFacebookAdsId: string | null; + adAccountId: string | null; + /** @enum {string} */ + eventType: "lead" | "purchase"; + trigger: { + /** @constant */ + type: "templateSent"; + templateIds: string[]; + } | { + /** @constant */ + type: "tagApplied"; + tagIds: string[]; + } | { + /** @constant */ + type: "keywordMatched"; + automatedResponseIds: string[]; + } | { + /** @constant */ + type: "contactReplied"; + firstReplyOnly: boolean; + }; + markAs: string | null; + enabled: boolean; + }[]; + }; + }; + }; + }; + }; + "appointmentCalendarsAPI.listAppointmentCalendarsForFlowAPI": { + parameters: { + query?: { + keyword?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + active: boolean; + }[]; + }; + }; + }; + }; + "appointmentsAPI.listContactAppointmentsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + calendarId: string; + contactId: string; + conversationId: string | null; + calendarName: string; + /** Format: date-time */ + startAt: string; + /** Format: date-time */ + endAt: string; + inviteeTimezone: string; + /** @enum {string} */ + status: "scheduled" | "cancelled"; + scheduleUrl: string; + }[]; + }; + }; + }; + }; + "aiMcpServerAPIs.validateAIMcpServerAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uri */ + url: string; + auth: { + /** @constant */ + type: "none"; + } | { + /** @constant */ + type: "token"; + token: string; + } | { + /** @constant */ + type: "header"; + headers: { + header: string; + value: string; + }[]; + }; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "aiMcpServerAPIs.listAIMcpServersAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + url: string; + auth: { + /** @constant */ + type: "none"; + } | { + /** @constant */ + type: "token"; + token: string; + } | { + /** @constant */ + type: "header"; + headers: { + header: string; + value: string; + }[]; + }; + availableTools: { + [key: string]: unknown; + }; + selectedTools: string[]; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "aiAgentsAPI.listAIAgentsAPI": { + parameters: { + query?: { + page?: number; + perPage?: number; + sort?: { + id: string; + desc: boolean; + }[]; + name?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id?: unknown; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + name: string; + prompt: string | null; + messages: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + isDefault: boolean; + isRichResponse: boolean; + tools: string[]; + webSearchAuthorizedDomains: string[]; + models: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + temperature: number; + maxOutputTokens: number; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "aiAgentsAPI.listAIAgentsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id?: unknown; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + name: string; + prompt: string | null; + messages: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + isDefault: boolean; + isRichResponse: boolean; + tools: string[]; + webSearchAuthorizedDomains: string[]; + models: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + temperature: number; + maxOutputTokens: number; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "broadcastAPIs.listBroadcastsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id?: unknown; + name: string; + /** @enum {string} */ + status: "scheduled" | "sent" | "sending" | "cancelled"; + /** @enum {string} */ + schedulesType: "now" | "future"; + /** Format: date-time */ + schedulesAt: string; + flowId?: unknown | null; + contactCount: number | null; + }[]; + }; + }; + }; + }; + }; + "broadcastAPIs.getBroadcastWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id?: unknown; + name: string; + /** @enum {string} */ + status: "scheduled" | "sent" | "sending" | "cancelled"; + /** @enum {string} */ + schedulesType: "now" | "future"; + /** Format: date-time */ + schedulesAt: string; + flowId?: unknown | null; + contactCount: number | null; + }; + }; + }; + }; + }; + "broadcastAPIs.getBroadcastAudienceWorkspaceTokenAPI": { + parameters: { + query?: { + page?: number; + perPage?: number; + }; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contact: { + id: string; + firstName: string | null; + lastName: string | null; + fullName: string | null; + email: string | null; + phoneNumber: string | null; + avatar: string | null; + gender: string | null; + }; + sent: boolean; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "broadcastAPIs.privateListBroadcastOptionsAPI": { + parameters: { + query: { + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "broadcastAPIs.privateGetBatchBroadcastStatsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + broadcastIds: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": number; + "message:failed": number; + }; + }; + }; + }; + }; + }; + "broadcastAPIs.privateGetBroadcastTemplateDetailAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + broadcastId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": ({ + /** @constant */ + channel: "whatsapp"; + id: string; + name: string; + language: string; + category: string; + status: string; + components?: unknown; + integrationName: string | null; + } | { + /** @constant */ + channel: "messenger"; + id: string; + name: string; + language: string; + category: string; + status: string; + parameterFormat: string; + components?: unknown; + integrationName: string | null; + }) | null; + }; + }; + }; + }; + "broadcastAPIs.privateListBroadcastContactsAPI": { + parameters: { + query?: { + eventType?: "message:sent" | "message:delivered" | "message:seen" | "message:failed" | "message:received" | "flow:clicked" | "flow:ref"; + total?: number; + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + broadcastId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + fullName: string | null; + sourceId: string | null; + avatar: string | null; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + errorContent: string | null; + occurredAt: string; + conversationId: string; + }[]; + total: number; + page: number; + pageCount: number; + }; + }; + }; + }; + }; + "conversationsAPI.listConversationsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @enum {string} */ + botCategory?: "bot" | "human" | "all"; + assignedId?: string | null; + channel?: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + status?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + keyword?: string; + botEnabled?: boolean | null; + tags?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + cursor?: string; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + contactInboxes: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + name: string; + }; + }[]; + messages: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + contact: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } | null; + assignedUser: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + })[]; + nextCursor: string | null; + prevCursor: string | null; + }; + }; + }; + }; + }; + "conversationsAPI.listConversationsByPOSTAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @enum {string} */ + botCategory?: "bot" | "human" | "all"; + assignedId?: string | null; + channel?: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + status?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + keyword?: string; + botEnabled?: boolean | null; + tags?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + cursor?: string; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + contactInboxes: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + name: string; + }; + }[]; + messages: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + contact: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } | null; + assignedUser: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + })[]; + nextCursor: string | null; + prevCursor: string | null; + }; + }; + }; + }; + }; + "conversationsAPI.findConversationAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + contactInboxes: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + name: string; + }; + }[]; + messages: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + contact: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } | null; + assignedUser: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + }; + }; + }; + }; + }; + }; + "conversationsAPI.getPostDetailsAuthenticatedAPI": { + parameters: { + query: { + inboxId: string; + postId: string; + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + text?: string; + picture?: string; + from?: { + id: string; + name: string; + }; + createdAt: string; + link?: string; + }; + }; + }; + }; + }; + "conversationsAPI.assignConversationsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + contactIds: string[]; + assignedId: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.archiveConversationsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.unarchiveConversationsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.enableBotAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.disableBotAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.readConversationAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.unreadConversationAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + agentLastReadAt: string | null; + }; + }; + }; + }; + }; + "conversationsAPI.followConversationAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.unfollowConversationAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "conversationsAPI.listConversationsWorkspaceTokenAPI": { + parameters: { + query?: { + botCategory?: "bot" | "human" | "all"; + assignedId?: string | null; + channel?: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + status?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + keyword?: string; + botEnabled?: boolean | null; + tags?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + cursor?: string; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + contactInboxes: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + name: string; + }; + }[]; + messages: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + contact: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } | null; + assignedUser: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + })[]; + nextCursor: string | null; + prevCursor: string | null; + }; + }; + }; + }; + }; + "couponsAPI.listCouponTopicsAPI": { + parameters: { + query?: { + archived?: boolean; + search?: string | null; + page?: number; + perPage?: number; + sort?: { + id: string; + desc: boolean; + }[]; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount: number; + }[]; + pageCount: number; + total: number; + }; + }; + }; + }; + }; + "couponsAPI.createCouponTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string | null; + expiresAt?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount?: number; + }; + }; + }; + }; + }; + "couponsAPI.deleteCouponTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + topicId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount?: number; + }; + }; + }; + }; + }; + "couponsAPI.updateCouponTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + topicId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string | null; + expiresAt?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount?: number; + }; + }; + }; + }; + }; + "couponsAPI.archiveCouponTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + topicId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount?: number; + }; + }; + }; + }; + }; + "couponsAPI.unarchiveCouponTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + topicId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + workspaceId: string; + name: string; + description: string | null; + expiresAt: string | null; + /** @enum {string} */ + status: "active" | "archived"; + deletedAt: string | null; + hasEverHadCoupon: boolean; + createdById: string | null; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + couponCount?: number; + }; + }; + }; + }; + }; + "couponsAPI.listCouponsAPI": { + parameters: { + query?: { + topicId?: string | null; + issueStatus?: ("published" | "unpublished") | null; + usageStatus?: ("used" | "notUsed") | null; + search?: string | null; + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + workspaceId: string; + topicId: string; + topicName: string; + code: string; + issuedContactId: string | null; + issuedAt: string | null; + usedAt: string | null; + /** @enum {string} */ + issueStatus: "published" | "unpublished"; + /** @enum {string} */ + usageStatus: "used" | "notUsed"; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }[]; + pageCount: number; + total: number; + }; + }; + }; + }; + }; + "couponsAPI.listCouponTopicOptionsAPI": { + parameters: { + query?: { + keyword?: string; + issueableOnly?: boolean; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + expiresAt: string | null; + }[]; + }; + }; + }; + }; + "couponsAPI.countCouponExportAPI": { + parameters: { + query?: { + topicId?: string | null; + issueStatus?: ("published" | "unpublished") | null; + usageStatus?: ("used" | "notUsed") | null; + search?: string | null; + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + count: number; + }; + }; + }; + }; + }; + "couponsAPI.getCouponExportFileAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + fileId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + status: "pending" | "uploaded" | "failed"; + fileName: string; + downloadUrl: string | null; + totalRecords: number | null; + }; + }; + }; + }; + }; + "couponsAPI.listContactCouponsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + topicId: string; + topicName: string; + code: string; + usedAt: string | null; + issuedAt: string | null; + }[]; + }; + }; + }; + }; + "deviceTokensAPI.registerDeviceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + workspaceId?: string; + /** @enum {string} */ + platform: "ios" | "android"; + token: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "deviceTokensAPI.unregisterDeviceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + token: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + }; + }; + }; + }; + }; + "dynamicImagesAPI.getDynamicImageAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + customFieldId: string | null; + data: { + width: number; + height: number; + elements: ({ + id: string; + x: number; + y: number; + width: number; + height: number; + /** @default false */ + priority: boolean; + /** @constant */ + type: "image"; + /** @enum {string} */ + imageType: "url" | "avatarUser" | "customField"; + url?: string; + customFieldId?: string; + /** + * @default square + * @enum {string} + */ + imageStyle: "square" | "circle"; + } | { + id: string; + x: number; + y: number; + width: number; + height: number; + /** @default false */ + priority: boolean; + /** @constant */ + type: "qrCode"; + text: string; + size: number; + color: string; + logoUrl?: string; + } | { + id: string; + x: number; + y: number; + width: number; + height: number; + /** @default false */ + priority: boolean; + /** @constant */ + type: "text"; + text: string; + fontSize: number; + /** @enum {string} */ + fontFamily: "arial" | "serif" | "roboto" | "greatVibes"; + /** @enum {string} */ + align: "left" | "center" | "right"; + color: string; + /** @default false */ + bold: boolean; + /** @default false */ + italic: boolean; + /** @default false */ + uppercase: boolean; + })[]; + }; + backgroundUrl: string | null; + enabled: boolean; + }; + }; + }; + }; + }; + "emailTopicsAPI.privateListWorkspaceEmailTopicsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId: string | null; + sendsTotal: number; + deliveredsTotal: number; + seensTotal: number; + clicksTotal: number; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "emailTopicsAPI.privateCreateWorkspaceEmailTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + }; + }; + }; + }; + }; + "emailTopicsAPI.privateUpdateEmailTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "emailTopicsAPI.privateDeleteEmailTopicAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "tagsAPI.privateListWorkspaceTagsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "tagsAPI.privateCreateWorkspaceTagAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + }; + }; + }; + }; + }; + "tagsAPI.privateUpdateTagAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "tagsAPI.privateDeleteTagsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "tagsAPI.listTagsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "tagsAPI.createTagWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "tagsAPI.getTagWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "tagsAPI.updateTagWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "tagsAPI.deleteTagWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.listCustomFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + }[]; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.createCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.getCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.updateCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string; + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.deleteCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "customFieldsAPI.privateListCustomFieldsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + folderId: string | null; + showInInbox: boolean; + workspaceId: string; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "customFieldsAPI.privateCreateCustomFieldAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + folderId?: string | null; + description?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + }; + }; + }; + }; + }; + "customFieldsAPI.privateUpdateCustomFieldAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + description?: string; + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "customFieldsAPI.privateDeleteCustomFieldsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + customFieldId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "flowsAPI.listFlowsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "flowsAPI.privateListFlowsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + active?: boolean | null; + startType?: string | null; + integrationWhatsappId?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + active: boolean; + enableInInbox: boolean; + currentVersionId: string | null; + draftVersionId: string | null; + workspaceId: string; + folderId: string | null; + } & { + flowVersions: { + id: string; + /** Format: date-time */ + createdAt: string; + workspaceId: string; + flowId: string; + nodes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + edges: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + isDraft: boolean; + isLatest: boolean; + startNodeId: string; + }[]; + })[]; + pageCount: number; + }; + }; + }; + }; + }; + "flowsAPI.privateGetFlowStatsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: { + node: { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": { + clicked: number; + totalUsers: number; + }; + "message:failed": number; + }; + buttons: { + [key: string]: { + buttonId: string; + clicks: number; + }; + }; + }; + }; + }; + }; + }; + }; + "flowsAPI.privateResetFlowStatsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "flowsAPI.privateListFlowVersionsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + workspaceId: string; + flowId: string; + nodes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + edges: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[])[]; + isDraft: boolean; + isLatest: boolean; + startNodeId: string; + }[]; + }; + }; + }; + }; + "flowsAPI.privateGetMessengerAdsJsonAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + status: "ok"; + json: string; + } | { + /** @constant */ + status: "error"; + /** @enum {string} */ + reason: "notPublished" | "noStartNode" | "invalidStepType" | "invalidVariable"; + }; + }; + }; + }; + }; + "flowsAPI.privateGetFlowContactStatsAPI": { + parameters: { + query?: { + eventType?: "message:sent" | "message:delivered" | "message:seen" | "message:failed" | "message:received" | "flow:clicked" | "flow:ref"; + nodeId?: string; + }; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + sourceId: string | null; + avatar: string | null; + channel: string | null; + conversationId: string; + occurredAt: string; + }[]; + total: number; + page: number; + pageCount: number; + }; + }; + }; + }; + }; + "contactsAPIs.listContactsWorkspaceTokenAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + })[]; + pageCount: number; + totalCount: number; + totalCountCapped: boolean; + }; + }; + }; + }; + }; + "contactsAPIs.createContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + phoneNumber?: "" | string; + email: "" | string; + contactId?: string; + firstName?: string; + lastName?: string; + /** @enum {string} */ + gender: "male" | "female" | "unknown"; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + inboxId: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + }; + }; + }; + }; + }; + "contactsAPIs.getContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + }; + }; + }; + }; + }; + "contactsAPIs.updateContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.deleteContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.filterContactsWorkspaceTokenAPI": { + parameters: { + query: { + customFieldId: string; + value: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + })[]; + }; + }; + }; + }; + }; + "contactsAPIs.listContactTagsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "contactsAPIs.addContactTagsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + tagIds: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.removeContactTagsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + tagIds: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.listContactCustomFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + } & { + value: string; + })[]; + }; + }; + }; + }; + }; + "contactsAPIs.setContactCustomFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + fields: { + customFieldId: string; + value: string; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.clearContactCustomFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.getContactCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + customFieldId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + } & { + value: string; + }; + }; + }; + }; + }; + "contactsAPIs.setContactCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + customFieldId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + value: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.clearContactCustomFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.blockContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.unblockContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.listContactMessagesWorkspaceTokenAPI": { + parameters: { + query?: { + perPage?: number; + cursor?: string; + }; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachments: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + nextCursor: string | null; + prevCursor: string | null; + }; + }; + }; + }; + }; + "contactsAPIs.sendMessageWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + text?: string; + files?: unknown[]; + flowId?: string; + nodeId?: string; + /** @description ID of the channel to send the message on. null to send message on the last interacted channel (if any). */ + inboxId?: string; + clientId?: string; + replyToMessageId?: string; + /** Format: date-time */ + replyToMessageCreatedAt?: string; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.getContactMessageWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + messageId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + }; + }; + }; + }; + }; + "contactsAPIs.triggerAutoReplyWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + keyword: string; + inboxId?: string; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.sendContactFlowWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + flowId: string; + inboxId?: string; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.importContactsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + fileId: string; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + inboxId: string; + timezone?: string; + countryCode?: string; + phoneNumber?: string; + contactId?: string; + sourceUserId?: string; + email?: string; + firstName?: string; + lastName?: string; + tagId?: string; + fieldMapping?: { + column: string; + customFieldId: string; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.upsertContactWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + identifier: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + firstName?: string; + lastName?: string; + email?: "" | string; + phoneNumber?: string; + avatar?: string; + /** @enum {string} */ + gender?: "male" | "female" | "unknown"; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + }; + }; + }; + }; + }; + "contactsAPIs.getContactAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + tags: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + customFields: ({ + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + } & { + value: string; + })[]; + contactNotes: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactsOnSequences: ({ + id?: unknown; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + /** Format: date-time */ + enrolledAt: string; + completedAt: string | null; + currentStep: number; + status: string | null; + nextRunAt: string | null; + lastStepId?: unknown | null; + nextStepId?: unknown | null; + lockedAt: string | null; + lockOwner: string | null; + lastError: string | null; + contactId: string; + sequenceId: string; + workspaceId?: unknown; + } & { + sequence: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + folderId: string | null; + active: boolean; + subscribers: number; + messages: number; + workspaceId: string; + }; + })[]; + }; + }; + }; + }; + }; + "contactsAPIs.listContactsAuthenticatedAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + })[]; + pageCount: number; + totalCount: number; + totalCountCapped: boolean; + }; + }; + }; + }; + }; + "contactsAPIs.createContactAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + phoneNumber?: "" | string; + email: "" | string; + contactId?: string; + firstName?: string; + lastName?: string; + /** @enum {string} */ + gender: "male" | "female" | "unknown"; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + inboxId: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + }; + }; + }; + }; + }; + "contactsAPIs.listContactsByPOSTAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + /** @enum {string} */ + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + } & { + contactCustomFields?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + value: string; + contactId: string; + customFieldId: string; + }[]; + tags?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + contactNotes?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + text: string; + contactId: string; + createdById: string | null; + }[]; + contactInboxes?: { + id: string; + contactId: string; + inboxId: string; + channel: string; + source: string; + sourceId: string; + sourceUserId?: string | null; + sourceUsername?: string | null; + language: string | null; + lastIncomingMessageAt: string | null; + contactLastReadAt?: string | null; + inbox: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + }; + }[]; + conversation?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + botEnabled: boolean; + botResumeAt: string | null; + archivedAt: string | null; + additionalAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + contactLastReadAt: string | null; + agentLastReadAt: string | null; + aiContextLastMessageId?: unknown | null; + lastActivityAt: string | null; + followed: boolean; + assignedUserId?: unknown | null; + assignedInboxTeamId?: unknown | null; + workspaceId: string; + contactId: string; + sourceId: string | null; + lastStep: string | null; + currentStep: string | null; + adminRepliedAt: string | null; + contactRepliedAt: string | null; + } & { + assignedUser?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + } | null; + assignedInboxTeam?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + } | null; + inbox?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + } | null; + }) | null; + })[]; + pageCount: number; + totalCount: number; + totalCountCapped: boolean; + }; + }; + }; + }; + }; + "contactsAPIs.countContactsAuthenticatedAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + total: number; + }; + }; + }; + }; + }; + "contactsAPIs.countContactInboxesAuthenticatedAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + total: number; + }; + }; + }; + }; + }; + "contactsAPIs.listContactInboxesAudiencePreviewAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + contactFilter?: { + /** @enum {string} */ + operator: "and" | "or"; + conditions: (({ + /** @constant */ + field: "locale"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "language"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fullName"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "country"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "continent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "gender"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToBroadcast"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedAt"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "source"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationTransferredToHuman"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "interactedInLast24h"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "followUp"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "archived"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "blocked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "currentChannel"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "inbox"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "timezone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteraction"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "contactCreatedDateMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastSeenMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastInteractionMinutesAgo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInput"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastComment"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "lastUserInputType"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "email"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "phone"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "hasContactInfo"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "tags"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSent"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastDelivered"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastSeen"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastClicked"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "broadcastFailed"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "subscribedToDripCampaign"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "entryPointsLinks"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireStarted"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireInProgress"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "questionnaireFinished"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "conversationAssigned"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unreplied"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "unread"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "existingContact"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "consecutiveAiFailures"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "emailWasVerified"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "optedInForEmail"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "fromCtwaAd"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + } | { + /** @constant */ + field: "ctwaConversion"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | string[] | [ + string, + string + ]; + }) | { + /** @constant */ + field: "customField"; + customFieldId: string; + /** @enum {string} */ + customFieldType?: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + /** @enum {string} */ + valueType: "multiSelect" | "select" | "text" | "boolean" | "datetime" | "number"; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string | [ + string, + string + ]; + } | { + /** @constant */ + field: "couponTopic"; + topicId: string; + /** @enum {string} */ + operator: "in" | "notIn" | "isEmpty" | "isNotEmpty" | "eq" | "ne" | "startsWith" | "endsWith" | "contains" | "notContains" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "notBetween" | "used"; + value?: string; + } | { + /** @constant */ + field: "ctwaRetarget"; + /** @enum {string} */ + segment: "conversations" | "leads" | "purchases"; + adId?: string; + integrationWhatsappId?: string; + since: string; + until: string; + })[]; + timezone?: string; + }; + channels?: ("omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok")[]; + integrationWhatsappId?: string; + integrationMessengerId?: string; + inboxIds?: string[]; + /** @enum {string} */ + subaction?: "allContacts" | "messengerActiveContacts" | "messengerTemplateMessage" | "whatsappTemplateMessage" | "whatsappWithin24Hours" | "instagramActiveContacts" | "telegramAllContacts" | "tiktokActiveContacts"; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + fullName: string | null; + avatar: string | null; + occurredAt: string | null; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + conversationId: string | null; + }[]; + }; + }; + }; + }; + }; + "contactsAPIs.getExportFileAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + fileId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + status: "pending" | "uploaded" | "failed"; + fileName: string; + downloadUrl: string | null; + totalRecords: number | null; + }; + }; + }; + }; + }; + "contactsAPIs.listContactTagsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + deletedAt: string | null; + folderId: string | null; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "contactsAPIs.addContactTagAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + ids: string[]; + tags: string[]; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.removeContactTagAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + tagId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.listContactFieldsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + description: string | null; + } & { + value: string; + })[]; + }; + }; + }; + }; + }; + "contactsAPIs.addContactFieldAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + customFieldId: string; + value: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "contactsAPIs.deleteContactFieldAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + contactId: string; + customFieldId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "botFieldAPIs.listBotFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + value: string | null; + }[]; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.setBotFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + fields: { + key: string; + value: string; + }[]; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.createBotFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + value: string | null; + description: string | null; + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + value: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.getBotFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + value: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.setBotFieldWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + value: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + /** @enum {string} */ + type: "shortText" | "email" | "phoneNumber" | "number" | "date" | "datetime" | "boolean" | "longText"; + value: string | null; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.deleteBotFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + idOrName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "botFieldAPIs.bulkUpdateBotFieldsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + fields: ({ + id: number; + value: string | number; + } | { + name: string; + value: string | number; + })[]; + }; + }; + }; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "integrationActiveCampaignAPI.listLists": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id?: unknown; + name?: string; + } & { + [key: string]: unknown; + })[]; + }; + }; + }; + }; + }; + "integrationActiveCampaignAPI.listAutomations": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id?: unknown; + name?: string; + } & { + [key: string]: unknown; + })[]; + }; + }; + }; + }; + }; + "integrationActiveCampaignAPI.listTags": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id?: unknown; + tag?: string; + name?: string; + } & { + [key: string]: unknown; + })[]; + }; + }; + }; + }; + }; + "integrationActiveCampaignAPI.listCustomFields": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: (unknown | null)[]; + }; + }; + }; + }; + }; + "integrationFacebookAdsAPI.listAdAccounts": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name?: string; + }[]; + }; + }; + }; + }; + }; + "integrationFacebookAdsAPI.listCustomAudiences": { + parameters: { + query: { + adAccountId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name?: string; + subtype?: string; + }[]; + }; + }; + }; + }; + }; + "integrationFacebookAdsAPI.getAdInsights": { + parameters: { + query: { + adAccountId: string; + since: string; + until: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + ad_id: string; + ad_name?: string; + spend?: unknown; + impressions?: unknown; + clicks?: unknown; + date_start?: string; + }[]; + }; + }; + }; + }; + }; + "integrationDripAPI.listAccounts": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id?: unknown; + name?: string; + }[]; + }; + }; + }; + }; + }; + "integrationDripAPI.listTags": { + parameters: { + query: { + accountId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: string[]; + }; + }; + }; + }; + }; + "integrationDripAPI.listCustomFields": { + parameters: { + query: { + accountId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + identifier: string; + label: string; + }[]; + }; + }; + }; + }; + }; + "integrationGetResponseAPI.listCampaigns": { + parameters: { + query?: { + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + campaignId: string; + name: string; + } & { + [key: string]: unknown; + })[]; + meta: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + }; + }; + }; + }; + }; + "integrationGetResponseAPI.listTags": { + parameters: { + query?: { + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + tagId: string; + name: string; + } & { + [key: string]: unknown; + })[]; + meta: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + }; + }; + }; + }; + }; + "integrationInstagramAPIs.setCoexistInstagramAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + integrationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + enabled: boolean; + /** @default false */ + aiReadsSyncedHistory?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + runId?: string; + } | { + /** @constant */ + success: false; + reason?: string; + msg?: string; + }; + }; + }; + }; + }; + "integrationKlaviyoAPI.listLists": { + parameters: { + query?: { + cursor?: string; + size?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + nextCursor: string | null; + }; + }; + }; + }; + }; + "integrationMailchimpAPI.listAudiences": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "integrationMailchimpAPI.listTags": { + parameters: { + query: { + listId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: number; + name: string; + }[]; + }; + }; + }; + }; + }; + "integrationMailchimpAPI.listMergeFields": { + parameters: { + query: { + listId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + tag: string; + name: string; + type: string; + required?: boolean; + }[]; + }; + }; + }; + }; + }; + "integrationMailerLiteAPI.listGroups": { + parameters: { + query?: { + page?: number; + limit?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + meta: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + }; + }; + }; + }; + }; + "integrationMailerLiteAPI.listFields": { + parameters: { + query?: { + page?: number; + limit?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + key: string; + /** @enum {string} */ + type: "text" | "number" | "date"; + }[]; + meta: { + currentPage: number; + lastPage: number; + perPage: number; + total: number; + }; + }; + }; + }; + }; + }; + "integrationMessengerAPIs.setCoexistMessengerAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + integrationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + enabled: boolean; + /** @default false */ + aiReadsSyncedHistory?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + } | { + /** @constant */ + success: false; + reason?: string; + msg?: string; + }; + }; + }; + }; + }; + "integrationMoosendAPI.listMailingLists": { + parameters: { + query?: { + page?: number; + pageSize?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + meta: { + pageSize: number; + currentPage: number; + totalResults: number; + totalPageCount: number; + }; + }; + }; + }; + }; + }; + "integrationSmtpAPI.listIntegrationSmtps": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + fromAddress: string; + }[]; + }; + }; + }; + }; + }; + "integrationSendGridAPI.listLists": { + parameters: { + query?: { + pageSize?: number; + pageToken?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + contactCount?: number; + }[]; + nextPageToken?: string; + count?: number; + }; + }; + }; + }; + }; + "integrationSendGridAPI.listCustomFields": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + /** @enum {string} */ + fieldType: "Text" | "Number" | "Date"; + }[]; + }; + }; + }; + }; + }; + "integrationWhatsappAPIs.listIntegrationWhatsappInternalAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + inboxId: string; + displayPhoneNumber: string; + tokenRefreshError: string | null; + phoneNumberId: string; + wabaId: string; + hasCapiScope: boolean; + capiScopeCheckedAt: string | null; + datasetId: string | null; + }[]; + }; + }; + }; + }; + "integrationWhatsappAPIs.setCoexistWhatsappAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + integrationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + enabled: boolean; + /** @default false */ + aiReadsSyncedHistory?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + success: true; + } | { + /** @constant */ + success: false; + reason?: "already_triggered" | "window_expired" | "not_eligible" | "trigger_failed" | string; + msg?: string; + }; + }; + }; + }; + }; + "whatsappMessageTemplateAPIs.listWhatsappMessageTemplatesInternalAPI": { + parameters: { + query?: { + inboxId?: string; + integrationWhatsappId?: string; + status?: "APPROVED" | "PENDING" | "REJECTED"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + language: string; + category: string; + status: string; + components?: unknown; + integrationWhatsappId?: unknown; + integrationWhatsapp: { + id: string; + name: string; + inboxId: string; + displayPhoneNumber: string; + tokenRefreshError: string | null; + phoneNumberId: string; + wabaId: string; + hasCapiScope: boolean; + capiScopeCheckedAt: string | null; + datasetId: string | null; + }; + }[]; + }; + }; + }; + }; + "whatsappMessageTemplateAPIs.listTemplateMessagesWorkspaceTokenAPI": { + parameters: { + query?: { + inboxId?: string; + integrationWhatsappId?: string; + status?: "APPROVED" | "PENDING" | "REJECTED"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + language: string; + category: string; + status: string; + components?: unknown; + integrationWhatsappId?: unknown; + integrationWhatsapp: { + id: string; + name: string; + inboxId: string; + displayPhoneNumber: string; + tokenRefreshError: string | null; + phoneNumberId: string; + wabaId: string; + hasCapiScope: boolean; + capiScopeCheckedAt: string | null; + datasetId: string | null; + }; + }[]; + }; + }; + }; + }; + "whatsappFlowAPIs.listWhatsappFlowsInternalAPI": { + parameters: { + query?: { + inboxId?: string; + integrationWhatsappId?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + sourceId: string; + status: string; + categories?: unknown; + validationErrors?: unknown; + completedCount: string; + integrationWhatsappId: string; + screens?: unknown; + integrationWhatsapp: { + id: string; + name: string; + inboxId: string; + displayPhoneNumber: string; + tokenRefreshError: string | null; + phoneNumberId: string; + wabaId: string; + hasCapiScope: boolean; + capiScopeCheckedAt: string | null; + datasetId: string | null; + }; + }[]; + }; + }; + }; + }; + "whatsappFlowAPIs.getWhatsappFlowScreensInternalAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + flowId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + screens: { + id: string; + title: string; + terminal: boolean; + output: { + value: string; + label: string; + }[]; + }[]; + }; + }; + }; + }; + }; + "messengerMessageTemplateAPIs.listMessengerMessageTemplatesInternalAPI": { + parameters: { + query?: { + inboxId?: string; + integrationMessengerId?: string; + status?: "APPROVED" | "PENDING" | "REJECTED"; + page?: number; + perPage?: number; + name?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + language: string; + category: string; + status: string; + components?: unknown; + parameterFormat: string; + integrationMessengerId?: unknown; + integrationMessenger: { + id: string; + name: string; + inboxId: string; + }; + }[]; + }; + }; + }; + }; + "savedRepliesAPI.listSavedRepliesAuthorizedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + shortcut: string; + text: string; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "savedRepliesAPI.listSavedRepliesWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + shortcut: string; + text: string; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "fbCommentsAPI.listFbCommentsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + isActive?: boolean | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "fbCommentsAPI.createFbCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** + * @default messenger + * @constant + */ + type?: "messenger"; + folderId?: string | null; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + }; + }; + "fbCommentsAPI.updateFbCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + name?: string; + /** + * @default messenger + * @constant + */ + type?: "messenger"; + folderId?: string | null; + post?: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply?: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply?: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords?: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords?: string[]; + options?: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments?: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter?: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + isActive?: boolean; + startTime?: string | null; + endTime?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + }; + }; + "fbCommentsAPI.deleteFbCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "fbCommentsAPI.facebookPostsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + published: { + id: string; + message?: string; + full_picture?: string; + created_time: string; + permalink_url?: string; + pageId: string; + }[]; + ads: { + id: string; + message?: string; + full_picture?: string; + created_time: string; + permalink_url?: string; + pageId: string; + }[]; + reels: { + id: string; + message?: string; + full_picture?: string; + created_time: string; + permalink_url?: string; + pageId: string; + }[]; + pages: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "igCommentsAPI.listIgCommentsAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + isActive?: boolean | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "igCommentsAPI.createIgCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @enum {string} */ + type: "instagram" | "instagramFacebook"; + folderId?: string | null; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + }; + }; + "igCommentsAPI.updateIgCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + name?: string; + /** @enum {string} */ + type?: "instagram" | "instagramFacebook"; + folderId?: string | null; + post?: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply?: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply?: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords?: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords?: string[]; + options?: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments?: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter?: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + isActive?: boolean; + startTime?: string | null; + endTime?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "messenger" | "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + post: { + /** @enum {string} */ + type: "published" | "ads" | "reels" | "postIds" | "all"; + value: string[]; + }; + privateReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + publicReply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + excludeKeywords: string[]; + options: { + replyToNewContactsOnly: boolean; + replyOncePerUserPerPost: boolean; + likeUserComment: boolean; + replyToUsersWhoCommentedOnOtherPosts: boolean; + ignoreCommentReplies: boolean; + trackUserTags: boolean; + }; + hideComments: { + all: boolean; + hasPhoneNumber: boolean; + hasImage: boolean; + hasVideo: boolean; + hasLink: boolean; + hasKeywords: boolean; + keywords: string[]; + /** @enum {string} */ + showCommentsAfter: "none" | "6h" | "12h" | "1d" | "2d" | "3d" | "4d" | "5d" | "6d" | "7d" | "8d" | "9d" | "10d"; + }; + replyAfter: { + /** @enum {string} */ + type: "immediately" | "seconds" | "minutes" | "hours" | "randomWithin3Minutes" | "randomWithin5Minutes" | "randomWithin10Minutes" | "randomWithin20Minutes" | "randomWithin30Minutes" | "randomWithin60Minutes"; + value: number; + }; + }; + }; + }; + }; + }; + "igCommentsAPI.deleteIgCommentAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "igCommentsAPI.instagramMediaAPI": { + parameters: { + query: { + variant: "instagram" | "instagramFacebook"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + posts: { + id: string; + message?: string; + full_picture?: string; + created_time: string; + permalink_url?: string; + media_product_type?: string; + accountId: string; + }[]; + pages: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "igStoriesAPI.listIgStoriesAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + isActive?: boolean | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + story: { + /** @enum {string} */ + type: "all" | "storyIds"; + value: string[]; + }; + reply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "igStoriesAPI.createIgStoryAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** @enum {string} */ + type: "instagram" | "instagramFacebook"; + folderId?: string | null; + story: { + /** @enum {string} */ + type: "all" | "storyIds"; + value: string[]; + }; + reply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + story: { + /** @enum {string} */ + type: "all" | "storyIds"; + value: string[]; + }; + reply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + }; + }; + }; + }; + }; + "igStoriesAPI.updateIgStoryAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + name?: string; + /** @enum {string} */ + type?: "instagram" | "instagramFacebook"; + folderId?: string | null; + story?: { + /** @enum {string} */ + type: "all" | "storyIds"; + value: string[]; + }; + reply?: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords?: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + isActive?: boolean; + startTime?: string | null; + endTime?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + workspaceId: string; + folderId?: string | null; + /** @enum {string} */ + type: "instagram" | "instagramFacebook"; + isActive: boolean; + startTime: string | null; + endTime: string | null; + repliesCount: number; + story: { + /** @enum {string} */ + type: "all" | "storyIds"; + value: string[]; + }; + reply: { + /** @enum {string} */ + type: "AIAgent" | "text" | "flow" | "none"; + value: string | null; + }; + includeKeywords: { + /** @enum {string} */ + type: "all" | "equal" | "contain"; + value: string[]; + }; + }; + }; + }; + }; + }; + "igStoriesAPI.deleteIgStoryAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "igStoriesAPI.instagramStoriesAPI": { + parameters: { + query: { + variant: "instagram" | "instagramFacebook"; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + stories: { + id: string; + message?: string; + full_picture?: string; + created_time: string; + permalink_url?: string; + accountId: string; + }[]; + pages: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "facebookLeadAdsAPI.listLeadAdsPagesAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + pages: { + pageId: string; + pageName: string; + eligible: boolean; + }[]; + }; + }; + }; + }; + }; + "facebookLeadAdsAPI.listLeadAdsFormsAPI": { + parameters: { + query: { + pageId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + forms: { + id: string; + name: string; + status: string; + questions?: { + key: string; + label: string; + type: string; + id: string; + }[]; + }[]; + }; + }; + }; + }; + }; + "sequencesAPI.listSequencesWorkspaceAuthAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + folderId?: string | null; + active?: boolean | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + folderId: string | null; + active: boolean; + subscribers: number; + messages: number; + workspaceId: string; + } & { + stepsCount: number; + subscribersCount: number; + })[]; + pageCount: number; + }; + }; + }; + }; + }; + "sequencesAPI.privateGetSequenceStepStatsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + sequenceId: string; + stepId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + "message:sent": number; + "message:delivered": number; + "message:seen": number; + "flow:clicked": number; + "message:failed": number; + }; + }; + }; + }; + }; + "sequencesAPI.privateListSequenceStepContactsAPI": { + parameters: { + query: { + eventType: "message:sent" | "message:delivered" | "message:seen" | "message:failed" | "message:received" | "flow:clicked" | "flow:ref"; + total?: number; + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + sequenceId: string; + stepId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + contactId: string; + contactInboxId: string; + firstName: string | null; + lastName: string | null; + fullName: string | null; + sourceId: string | null; + avatar: string | null; + /** @enum {string} */ + channel: "omnichannel" | "webchat" | "messenger" | "whatsapp" | "zalo" | "smtp" | "telegram" | "instagram" | "tiktok"; + errorContent: string | null; + occurredAt: string; + conversationId: string; + }[]; + total: number; + page: number; + pageCount: number; + }; + }; + }; + }; + }; + "sequencesAPI.listSequencesWorkspaceTokenAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + folderId: string | null; + active: boolean; + subscribers: number; + messages: number; + workspaceId: string; + } & { + stepsCount: number; + subscribersCount: number; + })[]; + pageCount: number; + }; + }; + }; + }; + }; + "sequencesAPI.getSequenceWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + folderId: string | null; + active: boolean; + subscribers: number; + messages: number; + workspaceId: string; + }; + }; + }; + }; + }; + "aiFilesAPI.listAIFilesAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + path: string; + size: number; + mimeType: string; + workspaceId: string; + url: string; + chunksCount: number; + /** @enum {string} */ + processingStatus: "pending" | "success" | "error" | "processing"; + }[]; + }; + }; + }; + }; + }; + "inboxesAPI.listInboxesAuthenticatedAPI": { + parameters: { + query?: { + includes?: "integration"[]; + page?: number; + perPage?: number; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + channel: string; + sourceId: string; + workspaceId: string; + status: string; + integrationWhatsapp?: { + id: string; + name: string; + inboxId: string; + displayPhoneNumber: string; + tokenRefreshError: string | null; + phoneNumberId: string; + wabaId: string; + hasCapiScope: boolean; + capiScopeCheckedAt: string | null; + datasetId: string | null; + } | null; + integrationWebchat?: { + id: string; + name: string; + } | null; + integrationMessenger?: { + id: string; + name: string; + inboxId: string; + } | null; + integrationZalo?: { + id: string; + name: string; + } | null; + integrationTelegram?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + auth: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + botId: string; + name: string; + workspaceId: string; + inboxId: string; + } | null; + integrationInstagram?: { + id: string; + name: string; + inboxId: string; + username: string; + } | null; + integrationSmtp?: { + id: string; + name: string; + fromAddress: string; + } | null; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "inboxesAPI.listInboxesWorkspaceTokenAPI": { + parameters: { + query?: { + includes?: "integration"[]; + page?: number; + perPage?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + channel: string; + status: string; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "inboxesAPI.listChannelsWorkspaceTokenAPI": { + parameters: { + query?: { + includes?: "integration"[]; + page?: number; + perPage?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + name: string; + channel: string; + status: string; + id: string; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "spreadsheetsAPI.listSpreadsheetsAuthenticatedAPI": { + parameters: { + query?: { + page?: number; + perPage?: number; + name?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + url: string; + spreadsheetId: string; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "spreadsheetsAPI.listWorksheetsAuthenticatedAPI": { + parameters: { + query: { + page?: number; + perPage?: number; + spreadsheetId: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: string[]; + }; + }; + }; + }; + }; + "spreadsheetsAPI.listWorksheetHeadersAuthenticatedAPI": { + parameters: { + query: { + sheetName: string; + }; + header?: never; + path: { + workspaceId: string; + spreadsheetId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: string[]; + }; + }; + }; + }; + }; + "workspaceMembersAPI.listWorkspaceMembersAuthenticatedAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + userId: string; + /** @enum {string} */ + role: "owner" | "agent"; + notificationChannels: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + notificationTypes: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + permissions: { + superAdmin: boolean; + analytics: boolean; + flows: boolean; + contacts: boolean; + onlyAssignedContacts: boolean; + emailAndPhone: boolean; + broadcast: boolean; + ecommerce: boolean; + }; + user: { + id: string; + name: string | null; + image: string | null; + }; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "workspaceMembersAPI.listMembersWorkspaceTokenAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + userId: string; + /** @enum {string} */ + role: "owner" | "agent"; + notificationChannels: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + notificationTypes: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + permissions: { + superAdmin: boolean; + analytics: boolean; + flows: boolean; + contacts: boolean; + onlyAssignedContacts: boolean; + emailAndPhone: boolean; + broadcast: boolean; + ecommerce: boolean; + }; + user: { + id: string; + name: string | null; + image: string | null; + }; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "workspaceMembersAPI.getMemberWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + memberId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + userId: string; + /** @enum {string} */ + role: "owner" | "agent"; + notificationChannels: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + notificationTypes: (string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]; + permissions: { + superAdmin: boolean; + analytics: boolean; + flows: boolean; + contacts: boolean; + onlyAssignedContacts: boolean; + emailAndPhone: boolean; + broadcast: boolean; + ecommerce: boolean; + }; + user: { + id: string; + name: string | null; + image: string | null; + }; + }; + }; + }; + }; + }; + "inboxTeamsAPI.listInboxTeamsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + inboxTeamMembers: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + inboxTeamId: string; + userId?: unknown; + user: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + }[]; + }[]; + }; + }; + }; + }; + }; + "inboxTeamsAPI.listTeamsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + name: string; + inboxTeamMembers: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + inboxTeamId: string; + userId?: unknown; + user: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + }[]; + }[]; + }; + }; + }; + }; + }; + "foldersAPI.listFoldersAuthenticatedAPI": { + parameters: { + query: { + folderType: string; + folderId?: string | null; + isTrash?: boolean | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + /** @enum {string} */ + folderType: "tag" | "flow" | "customField" | "automatedResponse" | "trigger" | "webhook" | "sequence" | "emailTopic" | "fbComment" | "igComment" | "igStory" | "outboundAutomatedResponse"; + parentId?: string | null; + workspaceId: string; + isTrash: boolean; + paths: (unknown | null)[]; + }[]; + }; + }; + }; + }; + }; + "messagesAPI.listMessagesAuthenticatedAPI": { + parameters: { + query?: { + perPage?: number; + cursor?: string; + conversationId?: string; + contactInboxId?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: (({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachments: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + })[]; + nextCursor: string | null; + prevCursor: string | null; + }; + }; + }; + }; + }; + "messagesAPI.findMessageAuthenticatedAPI": { + parameters: { + query: { + createdAt: string; + }; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + conversationId: string; + contactInboxId: string; + workspaceId: string; + text: string | null; + contentAttributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + /** @enum {string} */ + messageType: "incoming" | "outgoing" | "activity"; + /** @enum {string} */ + contentType: "text" | "location" | "refLink"; + /** @enum {string} */ + senderType: "bot" | "contact" | "system" | "user" | "api"; + senderId?: unknown | null; + sourceId: string | null; + deletedAt: string | null; + /** @enum {string} */ + type: "message" | "comment"; + parentId: string | null; + attributes: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + sendError: string | null; + } & { + clientId?: string; + }) & { + attachmentCount?: number; + attachments?: ({ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId?: unknown; + conversationId?: unknown; + messageId?: unknown; + /** Format: date-time */ + messageCreatedAt: string; + /** @enum {string} */ + fileType: "image" | "video" | "audio" | "gif" | "file"; + sourceId: string | null; + mimeType: string; + width: number | null; + height: number | null; + size: number; + thumbnailPath: string | null; + originPath: string; + name: string | null; + } & { + url?: string | null; + })[]; + user?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string | null; + email: string; + emailVerified: boolean; + image: string | null; + isAnonymous: boolean; + mustChangePassword: boolean; + tenantId?: unknown; + }; + contact?: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + avatar: string | null; + phoneNumber: string | null; + email: string | null; + emailVerified: boolean; + emailOptIn: boolean; + firstName: string | null; + lastName: string | null; + fullName: string | null; + gender: ("male" | "female" | "unknown") | null; + lastReadAt: string | null; + ref: string | null; + country: string | null; + state: string | null; + city: string | null; + location: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + locale: string | null; + timezone: string | null; + subscribedAt: string | null; + broadcastSubscribedAt: string | null; + blockedAt: string | null; + workspaceId: string; + }; + clientId?: string; + }; + }; + }; + }; + }; + "messagesAPI.createMessageAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + conversationId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + text?: string; + files?: unknown[]; + flowId?: string; + nodeId?: string; + /** @description ID of the channel to send the message on. null to send message on the last interacted channel (if any). */ + inboxId?: string; + clientId?: string; + replyToMessageId?: string; + /** Format: date-time */ + replyToMessageCreatedAt?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "messagesAPI.editMessageAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + conversationId: string; + messageId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: date-time */ + createdAt: string; + newText: string; + newAttachmentPath?: string; + newAttachmentPublicUrl?: string; + newAttachmentMimeType?: string; + newAttachmentName?: string; + newAttachmentSize?: number; + removeAttachment?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "messagesAPI.deleteMessageAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + conversationId: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: date-time */ + createdAt: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "messagesAPI.changeMessageAttributesAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + conversationId: string; + messageId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: date-time */ + createdAt: string; + liked?: boolean; + hidden?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + }; + }; + "personasAPIs.listMessengerPersonasAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + pageName: string; + }[]; + }; + }; + }; + }; + }; + "errorLogsAPI.listErrorLogsWorkspaceTokenAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + keyword?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + action: string; + detail: string; + httpCode: string | null; + workspaceId: string; + contactId: string | null; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "externalWebhooksAPI.listExternalWebhooksWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + provider: string; + event: string; + url: string; + }[]; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "externalWebhooksAPI.createExternalWebhookWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uri */ + url: string; + event: string; + /** + * @default make + * @enum {string} + */ + provider?: "make" | "n8n"; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + provider: string; + event: string; + url: string; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "externalWebhooksAPI.deleteExternalWebhookWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "workspacesAPI.getWorkspaceWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + defaultReply: string | null; + /** @enum {string} */ + defaultReplyFrequency: "allTime" | "oncePerHour" | "oncePerDay"; + targetCountry: string | null; + language: string; + timezone: string; + brandColor: string; + developmentMode: boolean; + smartResponseDelaySeconds: number | null; + isActive: boolean; + startTime: string | null; + endTime: string | null; + logo: string | null; + scheduledDeletionAt: string | null; + ownerId?: unknown; + tenantId?: unknown; + }; + }; + }; + }; + }; + "workspacesAPI.listMyWorkspacesAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + workspaces: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + defaultReply: string | null; + /** @enum {string} */ + defaultReplyFrequency: "allTime" | "oncePerHour" | "oncePerDay"; + targetCountry: string | null; + language: string; + timezone: string; + brandColor: string; + developmentMode: boolean; + smartResponseDelaySeconds: number | null; + isActive: boolean; + startTime: string | null; + endTime: string | null; + logo: string | null; + scheduledDeletionAt: string | null; + ownerId?: unknown; + tenantId?: unknown; + token: string | null; + }[]; + }; + }; + }; + }; + }; + "aiFunctionsAPI.listAIFunctionsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + purpose: string | null; + dataCollect: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + outputMessage: string | null; + triggerFlowId?: unknown | null; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "platformCredentialsAPI.getGiphyApiKeyAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + apiKey: string | null; + }; + }; + }; + }; + }; + "productsAPI.listProductFormOptionsAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + products: { + id: string; + name: string; + }[]; + vendors: string[]; + }; + }; + }; + }; + }; + "productsAPI.listProductsAuthorizedAPI": { + parameters: { + query?: { + page?: number | null; + perPage?: number | null; + sort?: { + id: string; + desc: boolean; + }[] | null; + name?: string | null; + categoryId?: string | null; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + shortDescription: string | null; + longDescription: string | null; + price: number; + taxes: number; + discount: number; + currency: string; + productUrl: string | null; + sku: string | null; + /** @enum {string} */ + inventoryPolicy: "dont_track" | "track"; + inventoryQuantity: number; + allowOutOfStockPurchase: boolean; + images: { + url: string; + /** @enum {string} */ + type: "link" | "file"; + }[]; + tags: string[]; + vendor: string | null; + rank: number; + categoryId: string | null; + subcategoryId: string | null; + isActive: boolean; + isSearchable: boolean; + allowSpecialRequest: boolean; + isAddonOnly: boolean; + workspaceId: string; + category: string | null; + subcategory: string | null; + }[]; + pageCount: number; + }; + }; + }; + }; + }; + "productCategoriesAPI.listProductCategoriesAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + parentId: string | null; + name: string; + rank: number; + productCount: number; + }[]; + }; + }; + }; + }; + "questionnairesAPI.listQuestionnairesForFlowAPI": { + parameters: { + query?: { + keyword?: string; + }; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + }[]; + }; + }; + }; + }; + "refLinksAPI.listRefLinkOptionsAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + name: string; + }[]; + }; + }; + }; + }; + }; + "refLinksAPI.getRefLinksAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + /** @enum {string} */ + type: "refLink" | "qrCode"; + flowId: string; + workspaceId: string; + customFieldId: string | null; + qrStyles: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + } | unknown; + }; + }; + }; + }; + "refLinksAPI.getRefLinkWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + /** @enum {string} */ + type: "refLink" | "qrCode"; + flowId: string; + workspaceId: string; + customFieldId: string | null; + qrStyles: ((string | number | boolean | null) | { + [key: string]: unknown; + } | (unknown | null)[]) | null; + }; + }; + }; + }; + }; + "keywordsAPI.listKeywordsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + folderId: string | null; + keywords: string[]; + status: boolean; + text: string | null; + flowId: string | null; + /** @enum {string} */ + type: "inbound" | "outbound"; + }[]; + }; + }; + }; + }; + }; + "integrationsAPI.listIntegrationsWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + workspaceId: string; + integrationType: string; + }[]; + }; + }; + }; + }; + }; + "triggersAPI.listTriggersWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + active: boolean; + folderId: string | null; + actions: (unknown | null)[]; + workspaceId: string; + conditions: (unknown | null)[]; + }[]; + }; + }; + }; + }; + }; + "userPersistentMenusAPI.listUserPersistentMenusAuthenticatedAPI": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + menus: ({ + label: string; + /** @constant */ + type: "flow"; + flowId: string; + } | { + label: string; + /** @constant */ + type: "url"; + /** Format: uri */ + url: string; + })[]; + workspaceId: string; + }[]; + }; + }; + }; + }; + }; + "webhooksAPI.listWebhooksWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + active: boolean; + folderId: string | null; + url: string; + workspaceId: string; + }[]; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "webhooksAPI.createWebhookWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + /** Format: uri */ + url: string; + conditions: ({ + id?: string; + /** @constant */ + type: "tagApplied"; + sourceId: string; + } | { + id?: string; + /** @constant */ + type: "tagRemoved"; + sourceId: string; + } | { + id?: string; + /** @constant */ + type: "customFieldValueChanged"; + sourceId: string; + operator: string; + value?: unknown; + } | { + id?: string; + /** @constant */ + type: "contactInfoUpdated"; + /** @enum {string} */ + sourceId: "phone" | "email"; + } | { + id?: string; + /** @constant */ + type: "dateTimeBasedTrigger"; + sourceId?: string; + operator: string; + value: { + /** @enum {string} */ + triggerType: "atTheDayOf" | "before" | "after"; + timeValue?: number; + /** @enum {string} */ + timeType?: "minutes" | "hours" | "days"; + at?: string; + timezone?: string; + }; + } | { + id?: string; + /** @constant */ + type: "conversationTransferredToHuman"; + } | { + id?: string; + /** @constant */ + type: "conversationTransferredToBot"; + } | { + id?: string; + /** @constant */ + type: "newContact"; + } | { + id?: string; + /** @constant */ + type: "contactUnsubscribedFormBroadcast"; + } | { + id?: string; + /** @constant */ + type: "archived"; + } | { + id?: string; + /** @constant */ + type: "followUp"; + } | { + id?: string; + /** @constant */ + type: "conversationAssigned"; + } | { + id?: string; + /** @constant */ + type: "conversationUnassigned"; + } | { + id?: string; + /** @constant */ + type: "subscribedToSequence"; + sourceId: string; + } | { + id?: string; + /** @constant */ + type: "unsubscribedFromSequence"; + sourceId: string; + } | { + id?: string; + /** @constant */ + type: "contactReferredANewContact"; + } | { + id?: string; + /** @constant */ + type: "contactReferredExistingContact"; + })[]; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + name: string; + active: boolean; + folderId: string | null; + url: string; + workspaceId: string; + }; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 422 */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "invalidRequestData"; + /** @constant */ + status: 422; + /** @default Validation error */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; + "webhooksAPI.deleteWebhookWorkspaceTokenAPI": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown | unknown; + }; + }; + /** @description 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "businessError"; + /** @constant */ + status: 400; + /** @default An error occurred while processing your request */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "notFound"; + /** @constant */ + status: 404; + /** @default Resource not found */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + /** @description 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + defined: true; + /** @constant */ + code: "rateLimitExceeded"; + /** @constant */ + status: 429; + /** @default Rate limit exceeded */ + message: string; + data?: unknown; + } | components["schemas"]["UndefinedError"]; + }; + }; + }; + }; +} diff --git a/src/api/pagination.test.ts b/src/api/pagination.test.ts new file mode 100644 index 0000000..690fa88 --- /dev/null +++ b/src/api/pagination.test.ts @@ -0,0 +1,32 @@ +import { flattenPages, getNextPageParam } from '@/api/pagination'; +import type { CursorPage } from '@/api/pagination'; + +describe('getNextPageParam', () => { + it('returns the cursor when present', () => { + const page: CursorPage<number> = { data: [1, 2], nextCursor: 'abc' }; + expect(getNextPageParam(page)).toBe('abc'); + }); + + it('returns undefined when nextCursor is null', () => { + const page: CursorPage<number> = { data: [1, 2], nextCursor: null }; + expect(getNextPageParam(page)).toBeUndefined(); + }); +}); + +describe('flattenPages', () => { + it('flattens data across multiple pages in order', () => { + const pages: CursorPage<number>[] = [ + { data: [1, 2], nextCursor: 'a' }, + { data: [3, 4], nextCursor: null }, + ]; + expect(flattenPages(pages)).toEqual([1, 2, 3, 4]); + }); + + it('returns an empty array for undefined pages', () => { + expect(flattenPages(undefined)).toEqual([]); + }); + + it('returns an empty array for an empty pages array', () => { + expect(flattenPages([])).toEqual([]); + }); +}); diff --git a/src/api/pagination.ts b/src/api/pagination.ts new file mode 100644 index 0000000..07a511e --- /dev/null +++ b/src/api/pagination.ts @@ -0,0 +1,20 @@ +/** + * Cursor-pagination helpers shared by every `useInfiniteQuery` hook. The generated schema's + * cursor-paginated list responses share the `{ data: T[], nextCursor: string | null, prevCursor: + * string | null }` shape — verified directly against every cursor-paginated operation in + * src/api/generated/schema.ts (conversations/list and 5 others); the real wire shape differs + * from the initial assumption of `{ items, nextCursor }`. + */ +export interface CursorPage<T> { + data: T[]; + nextCursor: string | null; + prevCursor?: string | null; +} + +export function getNextPageParam<T>(page: CursorPage<T>): string | undefined { + return page.nextCursor ?? undefined; +} + +export function flattenPages<T>(pages: CursorPage<T>[] | undefined): T[] { + return pages?.flatMap((page) => page.data) ?? []; +} diff --git a/src/api/query-keys.test.ts b/src/api/query-keys.test.ts new file mode 100644 index 0000000..cc5b1c1 --- /dev/null +++ b/src/api/query-keys.test.ts @@ -0,0 +1,53 @@ +import { isWorkspaceQuery, queryKeys } from '@/api/query-keys'; + +describe('queryKeys', () => { + it('builds workspace-scoped conversation list keys', () => { + expect(queryKeys.ws.conversations.list('w1', { status: 'unread' })).toEqual([ + 'ws', + 'w1', + 'conversations', + 'list', + { status: 'unread' }, + ]); + }); + + it('builds global session/workspaces keys without a workspace id', () => { + expect(queryKeys.session()).toEqual(['session']); + expect(queryKeys.workspaces()).toEqual(['workspaces']); + }); + + it('builds workspace-scoped sequences list key', () => { + expect(queryKeys.ws.sequences.list('w1')).toEqual(['ws', 'w1', 'sequences', 'list']); + }); + + it('builds workspace-scoped tags catalog key', () => { + expect(queryKeys.ws.tags.catalog('w1')).toEqual(['ws', 'w1', 'tags', 'catalog']); + }); + + it('builds workspace-scoped custom-fields catalog key', () => { + expect(queryKeys.ws.customFields.catalog('w1')).toEqual([ + 'ws', + 'w1', + 'custom-fields', + 'catalog', + ]); + }); +}); + +describe('isWorkspaceQuery', () => { + it('returns true for a matching workspace-scoped key', () => { + expect(isWorkspaceQuery(['ws', 'w1', 'conversations', 'list'], 'w1')).toBe(true); + }); + + it('returns false for a different workspace id', () => { + expect(isWorkspaceQuery(['ws', 'w1', 'conversations', 'list'], 'w2')).toBe(false); + }); + + it('returns false for a non-workspace-scoped key', () => { + expect(isWorkspaceQuery(['session'], 'w1')).toBe(false); + }); + + it('returns false for an empty key', () => { + expect(isWorkspaceQuery([], 'w1')).toBe(false); + }); +}); diff --git a/src/api/query-keys.ts b/src/api/query-keys.ts new file mode 100644 index 0000000..9692eca --- /dev/null +++ b/src/api/query-keys.ts @@ -0,0 +1,56 @@ +/** + * Central query-key factory. Every workspace-scoped key starts with `['ws', workspaceId, ...]` so + * a workspace switch can invalidate/clear everything for the old workspace with one + * `queryClient.removeQueries({ predicate: (query) => query.queryKey[0] === 'ws' && query.queryKey[1] === oldWorkspaceId })` + * call. Session/workspace-list keys are global (not workspace-scoped). + */ +export const queryKeys = { + session: () => ['session'] as const, + workspaces: () => ['workspaces'] as const, + + ws: { + conversations: { + list: (workspaceId: string, filters: Record<string, unknown>) => + ['ws', workspaceId, 'conversations', 'list', filters] as const, + detail: (workspaceId: string, conversationId: string) => + ['ws', workspaceId, 'conversations', 'detail', conversationId] as const, + }, + messages: { + list: (workspaceId: string, conversationId: string) => + ['ws', workspaceId, 'messages', 'list', conversationId] as const, + }, + contacts: { + list: (workspaceId: string, filters: Record<string, unknown>) => + ['ws', workspaceId, 'contacts', 'list', filters] as const, + detail: (workspaceId: string, contactId: string) => + ['ws', workspaceId, 'contacts', 'detail', contactId] as const, + }, + flows: { + list: (workspaceId: string) => ['ws', workspaceId, 'flows', 'list'] as const, + }, + sequences: { + list: (workspaceId: string) => ['ws', workspaceId, 'sequences', 'list'] as const, + }, + tags: { + catalog: (workspaceId: string) => ['ws', workspaceId, 'tags', 'catalog'] as const, + }, + customFields: { + catalog: (workspaceId: string) => ['ws', workspaceId, 'custom-fields', 'catalog'] as const, + }, + members: { + list: (workspaceId: string) => ['ws', workspaceId, 'members', 'list'] as const, + }, + teams: { + list: (workspaceId: string) => ['ws', workspaceId, 'teams', 'list'] as const, + }, + savedReplies: { + list: (workspaceId: string) => ['ws', workspaceId, 'saved-replies', 'list'] as const, + }, + }, +} as const; + +/** Predicate for `queryClient.removeQueries` on workspace switch — clears every `['ws', id, ...]` + * key for the given workspace. */ +export function isWorkspaceQuery(queryKey: readonly unknown[], workspaceId: string): boolean { + return queryKey[0] === 'ws' && queryKey[1] === workspaceId; +} diff --git a/src/app/(app)/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..b1efe96 --- /dev/null +++ b/src/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,73 @@ +import { Redirect, Tabs } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import type { ColorValue } from 'react-native'; + +import { Icon, type IconName } from '@/components/ui/icon'; +import { useInboxUnreadCount } from '@/features/conversations/api/use-inbox-unread-count'; +import { usePushTokenRotation } from '@/features/settings/api/use-device-token'; +import { RealtimeProvider } from '@/realtime/realtime-provider'; +import { useSettingsStore } from '@/stores/use-settings-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +/** react-navigation types tabBarIcon's `color` as `ColorValue` (includes opaque platform colors); + * our theme tokens are always plain hex strings, so this casts back for the Icon primitive. */ +function tabIcon(name: IconName) { + function TabIcon({ color, size }: { color: ColorValue; size: number }) { + return <Icon name={name} size={size} color={color as string} />; + } + return TabIcon; +} + +/** Tabs shell: guarded on having a selected workspace, wraps children in the realtime provider so + * every tab screen gets live updates. */ +export default function TabsLayout() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const pushEnabled = useSettingsStore((state) => state.pushEnabled); + const unreadCount = useInboxUnreadCount(currentWorkspaceId); + + // Lives here (not in Settings) so it re-registers the device token for whichever workspace is + // currently active, including after an inline workspace switch — the toggle itself stays a + // Settings-owned control (see settings/index.tsx's ToggleRow + its own register/unregister + // mutations for the user-initiated on/off path). + usePushTokenRotation(currentWorkspaceId ?? '', pushEnabled); + + if (!currentWorkspaceId) { + return <Redirect href="/(app)/workspace-picker" />; + } + + return ( + <RealtimeProvider> + <Tabs + screenOptions={{ + headerShown: false, + tabBarActiveTintColor: colors.brand, + tabBarInactiveTintColor: colors.textSecondary, + tabBarStyle: { backgroundColor: colors.surface1, borderTopColor: colors.borderSubtle }, + }} + > + <Tabs.Screen + name="conversations/index" + options={{ + title: t('inbox.title'), + tabBarIcon: tabIcon('chatbubbles'), + // `useInboxUnreadCount` only counts CACHED list pages, not a true server-side unread + // total (no such endpoint exists yet — see the hook's own doc comment). `undefined` + // (not 0) hides the badge entirely when there's nothing unread. + tabBarBadge: unreadCount || undefined, + }} + /> + <Tabs.Screen + name="contacts/index" + options={{ title: t('contacts.title'), tabBarIcon: tabIcon('people') }} + /> + <Tabs.Screen + name="settings/index" + options={{ title: t('settings.title'), tabBarIcon: tabIcon('settings') }} + /> + </Tabs> + </RealtimeProvider> + ); +} diff --git a/src/app/(app)/(tabs)/contacts/index.tsx b/src/app/(app)/(tabs)/contacts/index.tsx new file mode 100644 index 0000000..ba92abb --- /dev/null +++ b/src/app/(app)/(tabs)/contacts/index.tsx @@ -0,0 +1,96 @@ +import { FlashList } from '@shopify/flash-list'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, View } from 'react-native'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { ListFooterSpinner } from '@/components/ui/list-footer-spinner'; +import { Screen } from '@/components/ui/screen'; +import { SearchBar } from '@/components/ui/search-bar'; +import { SkeletonRow } from '@/components/ui/skeleton'; +import { WorkspaceBlockedGate } from '@/components/workspace-blocked-gate'; +import { normalizeApiError } from '@/api/errors'; +import { describeApiError } from '@/lib/describe-api-error'; +import { ContactRow } from '@/features/contacts/contact-row'; +import { + flattenContactPages, + useContactsInfinite, +} from '@/features/contacts/api/use-contacts-infinite'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +export default function ContactsScreen() { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const router = useRouter(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const [keyword, setKeyword] = useState(''); + + const query = useContactsInfinite(workspaceId, keyword); + const contacts = flattenContactPages(query.data?.pages); + const normalizedError = query.error ? normalizeApiError(query.error) : null; + + if (normalizedError?.kind === 'workspaceBlocked') { + return ( + <Screen edges={['top']}> + <WorkspaceBlockedGate reason={normalizedError.reason} message={normalizedError.message} /> + </Screen> + ); + } + + return ( + <Screen edges={['top']}> + <View style={{ padding: spacing.md, paddingBottom: spacing.sm }}> + <SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('common.search')} /> + </View> + + {normalizedError ? ( + <View style={{ paddingHorizontal: spacing.md }}> + <ErrorBanner + message={describeApiError(query.error, t)} + actionLabel={t('common.retry')} + onAction={() => query.refetch()} + /> + </View> + ) : null} + + {query.isLoading ? ( + <View> + {Array.from({ length: 8 }).map((_, index) => ( + <SkeletonRow.Contact key={index} /> + ))} + </View> + ) : contacts.length === 0 ? ( + <EmptyState icon="people-outline" title={t('contacts.empty')} /> + ) : ( + <FlashList + data={contacts} + keyExtractor={(contact) => contact.id} + renderItem={({ item }) => ( + <ContactRow + contact={item} + workspaceId={workspaceId} + onPress={() => router.push(`/(app)/contacts/${item.id}`)} + /> + )} + onEndReachedThreshold={0.4} + onEndReached={() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }} + refreshControl={ + <RefreshControl + refreshing={query.isRefetching} + onRefresh={() => query.refetch()} + tintColor={colors.brand} + /> + } + ListFooterComponent={<ListFooterSpinner visible={query.isFetchingNextPage} />} + /> + )} + </Screen> + ); +} diff --git a/src/app/(app)/(tabs)/conversations/index.tsx b/src/app/(app)/(tabs)/conversations/index.tsx new file mode 100644 index 0000000..0fd14f5 --- /dev/null +++ b/src/app/(app)/(tabs)/conversations/index.tsx @@ -0,0 +1,273 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { FlashList } from '@shopify/flash-list'; +import { router } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, StyleSheet, View } from 'react-native'; +import { useShallow } from 'zustand/react/shallow'; + +import { flattenPages } from '@/api/pagination'; +import { ConnectionBanner } from '@/components/ui/connection-banner'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { IconButton } from '@/components/ui/icon-button'; +import { ListFooterSpinner } from '@/components/ui/list-footer-spinner'; +import { Screen } from '@/components/ui/screen'; +import { SearchBar } from '@/components/ui/search-bar'; +import { SkeletonRow } from '@/components/ui/skeleton'; +import { useToast } from '@/components/ui/toast'; +import { WorkspaceBlockedGate } from '@/components/workspace-blocked-gate'; +import { normalizeApiError } from '@/api/errors'; +import { describeApiError } from '@/lib/describe-api-error'; +import { AssignmentSheet } from '@/features/conversations/components/assignment-sheet'; +import { + ConversationRow, + conversationRowItemType, +} from '@/features/conversations/components/conversation-row'; +import { FilterChipsRow } from '@/features/conversations/components/filter-chips-row'; +import { FilterSheet } from '@/features/conversations/components/filter-sheet'; +import { + useConversationsInfinite, + type ConversationListItem, +} from '@/features/conversations/api/use-conversations-infinite'; +import { + useArchiveConversations, + useAssignConversations, + useDisableBot, + useEnableBot, + useMarkConversationRead, + useMarkConversationUnread, + useUnarchiveConversations, +} from '@/features/conversations/api/use-conversation-actions'; +import { isBotActive, isUnread } from '@/features/conversations/lib/conversation-status'; +import { + conversationFiltersSnapshot, + useConversationFilters, +} from '@/features/conversations/stores/use-conversation-filters'; +import { WorkspaceSwitcherSheet } from '@/features/workspaces/components/workspace-switcher-sheet'; +import { WorkspacePill } from '@/features/workspaces/components/workspace-pill'; +import { useWorkspaces } from '@/features/workspaces/api/use-workspaces'; +import { useConnectionBannerState } from '@/realtime/use-connection-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +const SEARCH_DEBOUNCE_MS = 350; +const SKELETON_ROW_COUNT = 8; + +/** Count of active filter dimensions (excluding `keyword`, which has its own search bar affordance + * and doesn't belong in the "how many filters are on" badge) — drives the filter IconButton's + * `badgeCount`. */ +function activeFilterCount(filters: ReturnType<typeof conversationFiltersSnapshot>): number { + let count = 0; + if (filters.botCategory) count += 1; + if (filters.assignedId !== undefined) count += 1; + if (filters.channel) count += 1; + if (filters.status && filters.status.length > 0) count += 1; + if (filters.botEnabled !== undefined && filters.botEnabled !== null) count += 1; + return count; +} + +export default function ConversationsScreen() { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const showToast = useToast(); + const connectionState = useConnectionBannerState(); + + const [searchText, setSearchText] = useState(''); + const setKeyword = useConversationFilters((state) => state.setKeyword); + const resetFilters = useConversationFilters((state) => state.reset); + const filtersSnapshot = useConversationFilters(useShallow(conversationFiltersSnapshot)); + const filterCount = activeFilterCount(filtersSnapshot); + const [assigningConversation, setAssigningConversation] = useState<ConversationListItem | null>( + null, + ); + + const filterSheetRef = useRef<BottomSheet>(null); + const assignmentSheetRef = useRef<BottomSheet>(null); + const workspaceSwitcherSheetRef = useRef<BottomSheet>(null); + const { data: workspaces } = useWorkspaces(); + const currentWorkspace = workspaces?.find((workspace) => workspace.id === workspaceId); + + useEffect(() => { + const timeout = setTimeout(() => setKeyword(searchText), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timeout); + }, [searchText, setKeyword]); + + const query = useConversationsInfinite(workspaceId); + const conversations = flattenPages(query.data?.pages); + const assignMutation = useAssignConversations(workspaceId ?? ''); + const markRead = useMarkConversationRead(workspaceId ?? ''); + const markUnread = useMarkConversationUnread(workspaceId ?? ''); + const enableBot = useEnableBot(workspaceId ?? ''); + const disableBot = useDisableBot(workspaceId ?? ''); + const archiveConversations = useArchiveConversations(workspaceId ?? ''); + const unarchiveConversations = useUnarchiveConversations(workspaceId ?? ''); + + const openAssignmentSheet = useCallback((conversation: ConversationListItem) => { + setAssigningConversation(conversation); + assignmentSheetRef.current?.expand(); + }, []); + + function handleAssign(userId: string | null) { + if (!assigningConversation?.contactId) return; + assignMutation.mutate({ contactIds: [assigningConversation.contactId], assignedId: userId }); + } + + function handleToggleRead(conversation: ConversationListItem) { + if (isUnread(conversation)) { + markRead.mutate(conversation.id); + } else { + markUnread.mutate(conversation.id); + } + } + + function handleToggleBot(conversation: ConversationListItem) { + if (isBotActive(conversation.botEnabled, conversation.botResumeAt)) { + disableBot.mutate([conversation.id]); + } else { + enableBot.mutate([conversation.id]); + } + } + + function handleArchive(conversation: ConversationListItem) { + archiveConversations.mutate([conversation.id]); + showToast({ + message: t('conversations.archive'), + tone: 'neutral', + action: { + label: t('conversations.undo'), + onPress: () => unarchiveConversations.mutate([conversation.id]), + }, + }); + } + + const listEmptyComponent = useMemo(() => { + if (filterCount > 0 || filtersSnapshot.keyword) { + return ( + <EmptyState + icon="filter-outline" + title={t('conversations.emptyFiltered')} + action={{ label: t('conversations.clearFilters'), onPress: resetFilters }} + /> + ); + } + return <EmptyState icon="chatbubbles-outline" title={t('conversations.empty')} />; + }, [filterCount, filtersSnapshot.keyword, resetFilters, t]); + + const normalizedError = query.error ? normalizeApiError(query.error) : null; + if (normalizedError?.kind === 'workspaceBlocked') { + return ( + <Screen edges={['top']}> + <WorkspaceBlockedGate reason={normalizedError.reason} message={normalizedError.message} /> + </Screen> + ); + } + + return ( + <Screen edges={['top']}> + <View style={[styles.header, { padding: spacing.md, gap: spacing.sm }]}> + {currentWorkspace ? ( + <WorkspacePill + name={currentWorkspace.name} + logo={currentWorkspace.logo} + onPress={() => workspaceSwitcherSheetRef.current?.expand()} + /> + ) : null} + <View style={styles.searchRow}> + <View style={styles.searchInput}> + <SearchBar + value={searchText} + onChangeText={setSearchText} + placeholder={t('common.search') ?? undefined} + /> + </View> + <IconButton + accessibilityLabel={t('conversations.filters')} + icon="filter" + variant="tonal" + badgeCount={filterCount} + onPress={() => filterSheetRef.current?.expand()} + /> + </View> + </View> + + <ConnectionBanner state={connectionState} /> + + <FilterChipsRow /> + + {query.isError ? ( + <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.sm }}> + <ErrorBanner + message={describeApiError(query.error, t)} + actionLabel={t('common.retry') ?? undefined} + onAction={() => query.refetch()} + /> + </View> + ) : null} + + {query.isPending ? ( + <View style={{ paddingTop: spacing.sm }}> + {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => ( + <SkeletonRow.Conversation key={index} /> + ))} + </View> + ) : ( + <FlashList + data={conversations} + keyExtractor={(item) => item.id} + getItemType={conversationRowItemType} + renderItem={({ item }) => ( + <ConversationRow + conversation={item} + onPress={() => router.push(`/(app)/conversations/${item.id}`)} + onLongPress={() => openAssignmentSheet(item)} + onToggleRead={() => handleToggleRead(item)} + onToggleBot={() => handleToggleBot(item)} + onArchive={() => handleArchive(item)} + /> + )} + onEndReachedThreshold={0.4} + onEndReached={() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }} + refreshControl={ + <RefreshControl + refreshing={query.isRefetching} + onRefresh={() => query.refetch()} + tintColor={colors.brand} + /> + } + ListEmptyComponent={listEmptyComponent} + ListFooterComponent={<ListFooterSpinner visible={query.isFetchingNextPage} />} + /> + )} + + <FilterSheet sheetRef={filterSheetRef} /> + {workspaceId ? ( + <AssignmentSheet + sheetRef={assignmentSheetRef} + workspaceId={workspaceId} + onAssign={handleAssign} + /> + ) : null} + <WorkspaceSwitcherSheet ref={workspaceSwitcherSheetRef} /> + </Screen> + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'column', + }, + searchRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + searchInput: { + flex: 1, + }, +}); diff --git a/src/app/(app)/(tabs)/settings/index.tsx b/src/app/(app)/(tabs)/settings/index.tsx new file mode 100644 index 0000000..57474fd --- /dev/null +++ b/src/app/(app)/(tabs)/settings/index.tsx @@ -0,0 +1,188 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import Constants from 'expo-constants'; +import { router } from 'expo-router'; +import * as Notifications from 'expo-notifications'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, ScrollView, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { Avatar } from '@/components/ui/avatar'; +import { Card } from '@/components/ui/surface'; +import { Divider } from '@/components/ui/divider'; +import { ListItem } from '@/components/ui/list-item'; +import { Screen } from '@/components/ui/screen'; +import { SectionHeader } from '@/components/ui/section-header'; +import { SegmentedTabs } from '@/components/ui/segmented-tabs'; +import { Text } from '@/components/ui/text'; +import { ToggleRow } from '@/components/ui/toggle-row'; +import { useSignOut } from '@/features/auth/use-sign-out'; +import { + useRegisterDeviceToken, + useUnregisterDeviceToken, +} from '@/features/settings/api/use-device-token'; +import { LanguageSheet } from '@/features/settings/components/language-sheet'; +import { WorkspaceSwitcherSheet } from '@/features/workspaces/components/workspace-switcher-sheet'; +import { useWorkspaces } from '@/features/workspaces/api/use-workspaces'; +import { localeMeta } from '@/i18n/locales'; +import { useReducedMotion } from '@/theme/motion'; +import { useResolvedScheme, useTheme } from '@/theme/use-theme'; +import { useAuthStore } from '@/stores/use-auth-store'; +import type { ThemePreference } from '@/stores/use-settings-store'; +import { useSettingsStore } from '@/stores/use-settings-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +const appVersion = Constants.expoConfig?.version ?? null; + +export default function SettingsScreen() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const scheme = useResolvedScheme(); + const reducedMotion = useReducedMotion(); + + const user = useAuthStore((state) => state.user); + const signOut = useSignOut(); + + const themePreference = useSettingsStore((state) => state.themePreference); + const setThemePreference = useSettingsStore((state) => state.setThemePreference); + const language = useSettingsStore((state) => state.language); + + const languageSheetRef = useRef<BottomSheet>(null); + const workspaceSwitcherSheetRef = useRef<BottomSheet>(null); + + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { data: workspaces } = useWorkspaces(); + const currentWorkspace = workspaces?.find((workspace) => workspace.id === currentWorkspaceId); + + // Persisted user *intent* — the effective toggle state also depends on OS permission, which + // can be revoked outside the app, so this isn't the only source of truth for what's displayed. + // Token rotation itself (re-registering on a new Expo push token) is handled once, workspace- + // aware, in the tabs layout — this screen only owns the on/off toggle and its mutations. + const pushEnabled = useSettingsStore((state) => state.pushEnabled); + const setPushEnabled = useSettingsStore((state) => state.setPushEnabled); + const registerDeviceToken = useRegisterDeviceToken(currentWorkspaceId ?? ''); + const unregisterDeviceToken = useUnregisterDeviceToken(); + + async function handleTogglePush(value: boolean) { + if (!value) { + setPushEnabled(false); + unregisterDeviceToken.mutate(); + return; + } + + const permission = await Notifications.getPermissionsAsync(); + if (permission.status === 'denied' && !permission.canAskAgain) { + // Denied-forever: the OS won't show the prompt again — only Settings can flip it. + Linking.openSettings(); + return; + } + + registerDeviceToken.mutate(undefined, { + onSuccess: () => setPushEnabled(true), + onError: () => setPushEnabled(false), + }); + } + + return ( + <Screen edges={['top']}> + <Animated.View + key={reducedMotion ? undefined : scheme} + entering={reducedMotion ? undefined : FadeIn.duration(200)} + style={{ flex: 1 }} + > + <ScrollView contentContainerStyle={{ padding: spacing.md, gap: spacing.lg }}> + <Text variant="display">{t('settings.title')}</Text> + + <Card> + <ListItem + title={user?.name ?? user?.email ?? t('settings.profile')} + subtitle={user?.name ? user.email : undefined} + leading={<Avatar name={user?.name ?? user?.email ?? '?'} size={56} />} + onPress={() => router.push('/(app)/profile')} + showChevron + /> + </Card> + + <View> + <SectionHeader title={t('settings.workspace')} /> + <Card> + <ListItem + title={t('settings.switchWorkspace')} + value={currentWorkspace?.name} + leading={ + currentWorkspace ? ( + <Avatar uri={currentWorkspace.logo} name={currentWorkspace.name} size={36} /> + ) : undefined + } + onPress={() => workspaceSwitcherSheetRef.current?.expand()} + showChevron + /> + <Divider inset /> + <ListItem + title={t('settings.members')} + onPress={() => router.push('/(app)/settings/members')} + showChevron + /> + </Card> + <WorkspaceSwitcherSheet ref={workspaceSwitcherSheetRef} /> + </View> + + <View> + <SectionHeader title={t('settings.appearance')} /> + <Card> + <View style={{ paddingHorizontal: spacing.md, paddingVertical: spacing.ms }}> + <Text variant="body" style={{ marginBottom: spacing.xs }}> + {t('settings.theme')} + </Text> + <SegmentedTabs<ThemePreference> + options={[ + { value: 'system', label: t('settings.themeSystem') }, + { value: 'light', label: t('settings.themeLight') }, + { value: 'dark', label: t('settings.themeDark') }, + ]} + value={themePreference} + onChange={setThemePreference} + /> + </View> + <Divider inset /> + <ListItem + title={t('settings.language')} + value={localeMeta[language].nativeLabel} + onPress={() => languageSheetRef.current?.expand()} + showChevron + /> + </Card> + <LanguageSheet sheetRef={languageSheetRef} currentLanguage={language} /> + </View> + + <View> + <SectionHeader title={t('settings.notifications')} /> + <Card> + <ToggleRow + label={t('settings.pushNotifications')} + description={t('settings.pushNotificationsDescription')} + value={pushEnabled} + onValueChange={(value) => void handleTogglePush(value)} + disabled={registerDeviceToken.isPending || unregisterDeviceToken.isPending} + /> + </Card> + </View> + + <Card> + <ListItem title={t('auth.signOut')} onPress={signOut} destructive /> + </Card> + + {appVersion ? ( + <Text + variant="micro" + color="tertiary" + style={{ textAlign: 'center', marginTop: spacing.sm }} + > + {t('settings.version', { version: appVersion })} + </Text> + ) : null} + </ScrollView> + </Animated.View> + </Screen> + ); +} diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx new file mode 100644 index 0000000..59ff9d4 --- /dev/null +++ b/src/app/(app)/_layout.tsx @@ -0,0 +1,42 @@ +import { Redirect, Stack } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { useAuthStore } from '@/stores/use-auth-store'; + +/** App route group: gated on an active session. A must-change-password user is trapped in + * `(auth)/change-password` (that layout's own logic), so reaching here implies the gate is clear. */ +export default function AppLayout() { + const { t } = useTranslation(); + const status = useAuthStore((state) => state.status); + + // 'pending' means the root bootstrap gate hasn't resolved the session yet — the splash screen + // is normally still up at that point, but render nothing rather than redirecting, so a re-mount + // of this layout mid-bootstrap never bounces a still-being-verified signed-in user to sign-in. + if (status === 'pending') { + return null; + } + + if (status !== 'signed-in') { + return <Redirect href="/(auth)/sign-in" />; + } + + return ( + <Stack screenOptions={{ headerShown: false }}> + <Stack.Screen name="(tabs)" /> + <Stack.Screen name="workspace-picker" /> + <Stack.Screen + name="profile" + options={{ presentation: 'modal', headerShown: true, title: t('settings.profile') }} + /> + <Stack.Screen name="conversations/[conversationId]/index" /> + <Stack.Screen + name="conversations/[conversationId]/contact" + options={{ headerShown: true, title: t('contacts.contactTitle') }} + /> + <Stack.Screen + name="settings/members" + options={{ headerShown: true, title: t('settings.members') }} + /> + </Stack> + ); +} diff --git a/src/app/(app)/contacts/[contactId].tsx b/src/app/(app)/contacts/[contactId].tsx new file mode 100644 index 0000000..58418dc --- /dev/null +++ b/src/app/(app)/contacts/[contactId].tsx @@ -0,0 +1,15 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { ContactPanel } from '@/features/contacts/contact-panel'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** Standalone contact detail, reached from the contacts-tab directory. Reuses the same panel as + * the conversation-nested route (contact.tsx). */ +export default function ContactDetailScreen() { + const { contactId } = useLocalSearchParams<{ contactId: string }>(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + return ( + <ContactPanel workspaceId={workspaceId} contactId={contactId ?? null} origin="directory" /> + ); +} diff --git a/src/app/(app)/conversations/[conversationId]/contact.tsx b/src/app/(app)/conversations/[conversationId]/contact.tsx new file mode 100644 index 0000000..4c7276f --- /dev/null +++ b/src/app/(app)/conversations/[conversationId]/contact.tsx @@ -0,0 +1,44 @@ +import { useLocalSearchParams } from 'expo-router'; +import { View } from 'react-native'; + +import { Screen } from '@/components/ui/screen'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useConversationDetail } from '@/features/conversations/api/use-conversation-detail'; +import { ContactPanel } from '@/features/contacts/contact-panel'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +/** Keyed by conversationId, so resolves contactId via the conversation-detail fetch before + * handing off to the shared ContactPanel. */ +export default function ConversationContactScreen() { + const { conversationId } = useLocalSearchParams<{ conversationId: string }>(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { spacing } = useTheme(); + const { data: conversation, isLoading } = useConversationDetail( + workspaceId, + conversationId ?? null, + ); + + if (isLoading || !conversation) { + // Matches ContactPanel's own loading shape (see contact-panel.tsx) — this gate sits in front + // of it, resolving `contactId` via the conversation-detail fetch, so the two loading states + // should look identical rather than swap shapes mid-load. + return ( + <Screen padded> + <Skeleton height={72} borderRadius={36} style={{ alignSelf: 'center' }} /> + <View style={{ height: spacing.md }} /> + <Skeleton height={16} /> + </Screen> + ); + } + + return ( + <ContactPanel + workspaceId={workspaceId} + contactId={conversation.contactId} + origin="conversation" + channels={conversation.contactInboxes.map((inbox) => inbox.channel)} + conversationId={conversation.id} + /> + ); +} diff --git a/src/app/(app)/conversations/[conversationId]/index.tsx b/src/app/(app)/conversations/[conversationId]/index.tsx new file mode 100644 index 0000000..6baa310 --- /dev/null +++ b/src/app/(app)/conversations/[conversationId]/index.tsx @@ -0,0 +1,255 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type BottomSheet from '@gorhom/bottom-sheet'; +import type { LayoutChangeEvent } from 'react-native'; +import { KeyboardAvoidingView, Platform, Keyboard } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { flattenPages } from '@/api/pagination'; +import { ConnectionBanner } from '@/components/ui/connection-banner'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Screen } from '@/components/ui/screen'; +import { WorkspaceBlockedGate } from '@/components/workspace-blocked-gate'; +import { normalizeApiError } from '@/api/errors'; +import { describeApiError } from '@/lib/describe-api-error'; +import { useMarkConversationRead } from '@/features/conversations/api/use-conversation-actions'; +import { findConversationInListCache } from '@/features/conversations/lib/find-conversation-in-cache'; +import { isUnread } from '@/features/conversations/lib/conversation-status'; +import { ChatHeader } from '@/features/chat/components/chat-header'; +import { Composer } from '@/features/chat/components/composer'; +import { MessageActionsMenu } from '@/features/chat/components/message-actions-menu'; +import { MessageList, type MessageListHandle } from '@/features/chat/components/message-list'; +import { useMessagesInfinite, type Message } from '@/features/chat/api/use-messages-infinite'; +import { useDeleteMessage, useSetMessageAttributes } from '@/features/chat/api/use-message-actions'; +import { removeOptimisticMessage, useSendMessage } from '@/features/chat/api/use-send-message'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { useConnectionBannerState } from '@/realtime/use-connection-store'; +import { queryKeys } from '@/api/query-keys'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** Sibling of `(app)/(tabs)`, not nested inside it — the tab bar is hidden for this screen + * (declared as a plain Stack.Screen in (app)/_layout.tsx, no Tabs wrapper). */ +export default function ChatScreen() { + const { t } = useTranslation(); + const { conversationId } = useLocalSearchParams<{ conversationId: string }>(); + const router = useRouter(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const queryClient = useQueryClient(); + const insets = useSafeAreaInsets(); + + const [selectedMessage, setSelectedMessage] = useState<Message | null>(null); + const [headerHeight, setHeaderHeight] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const actionsMenuRef = useRef<BottomSheet>(null); + const messageListRef = useRef<MessageListHandle>(null); + + const messagesQuery = useMessagesInfinite(workspaceId, conversationId ?? null); + const messages = useMemo(() => flattenPages(messagesQuery.data?.pages), [messagesQuery.data]); + // `messages` is newest-first (see use-messages-infinite.ts); MessageList wants chronological + // order. Memoized on `messages` itself (not recomputed inline) so downstream `messagesById`/ + // `rows` memos that depend on this array's identity don't invalidate on every render. + const chronologicalMessages = useMemo(() => [...messages].reverse(), [messages]); + + const markRead = useMarkConversationRead(workspaceId ?? ''); + const sendMessage = useSendMessage(workspaceId ?? '', conversationId ?? ''); + // Read from the shared chat store (set by `useSendMessage`'s `onSuccess`) rather than this + // screen's own `sendMessage.data` — that mutation instance is used ONLY for the retry path + // here; the composer instantiates its own separate `useSendMessage(...)` for normal sends, so + // `sendMessage.data` never reflected a composer send at all. The store is the one place both + // instances can report to. + const justSentMessageId = + useChatStore((state) => + conversationId ? state.justSentClientIdByConversation[conversationId] : undefined, + ) ?? null; + const deleteMessage = useDeleteMessage(workspaceId ?? '', conversationId ?? ''); + const setAttributes = useSetMessageAttributes(workspaceId ?? '', conversationId ?? ''); + const setComposerMode = useChatStore((state) => state.setComposerMode); + const setDraft = useChatStore((state) => state.setDraft); + const setActiveConversationId = useChatStore((state) => state.setActiveConversationId); + const connectionState = useConnectionBannerState(); + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvent, () => setIsKeyboardVisible(true)); + const hideSub = Keyboard.addListener(hideEvent, () => setIsKeyboardVisible(false)); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + useFocusEffect( + useCallback(() => { + if (!conversationId || !workspaceId) return; + const conversation = findConversationInListCache(queryClient, workspaceId, conversationId); + // Guard: previously this fired unconditionally on every focus, generating a network call + // even when the conversation was already read. Only mark-read when there's something to + // mark — and only when we actually know the unread state (a cache miss, e.g. a cold deep + // link with no list query loaded yet, is treated as "unknown, don't guess" rather than + // either always or never firing). + if (conversation && isUnread(conversation)) { + markRead.mutate(conversationId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mark-read is fire-and-forget on focus, re-running per markRead identity would loop. + }, [conversationId, workspaceId]), + ); + + // Tracks which conversation is currently on-screen so `src/lib/notifications.ts`'s foreground + // notification handler (called from outside React, via `useChatStore.getState()`) can suppress + // the in-app banner for a push about the conversation the user is already viewing. Cleared on + // blur/unmount rather than left set, so navigating away (or the app backgrounding) doesn't keep + // suppressing notifications for a conversation that's no longer visible. + useFocusEffect( + useCallback(() => { + if (!conversationId) return undefined; + setActiveConversationId(conversationId); + return () => setActiveConversationId(null); + }, [conversationId, setActiveConversationId]), + ); + + function handleRetry(message: Message) { + if (!message.clientId) return; + + // Rebuild the ORIGINAL send params from the failed optimistic row — previously this only + // re-sent `text`, silently dropping any attachments or reply context, so retrying a failed + // attachment/reply send produced a plain text message instead of the thing the user actually + // tried to send. + const attachments = + message.attachments.length > 0 + ? message.attachments.map((attachment) => ({ + uri: attachment.originPath, + mimeType: attachment.mimeType, + fileName: attachment.name ?? attachment.originPath.split('/').pop() ?? 'attachment', + })) + : undefined; + + // `parentId` only carries the parent's id, not its `createdAt` (which `replyTo` needs) — + // recover it from the already-loaded messages list rather than a fresh cache lookup. + const parent = message.parentId + ? messages.find((candidate) => candidate.id === message.parentId) + : undefined; + const replyTo = parent ? { messageId: parent.id, createdAt: parent.createdAt } : undefined; + + sendMessage.mutate({ + workspaceId: workspaceId ?? '', + conversationId: conversationId ?? '', + text: message.text ?? undefined, + attachments, + replyTo, + clientId: message.clientId, + }); + } + + function handleLongPress(message: Message) { + setSelectedMessage(message); + actionsMenuRef.current?.expand(); + } + + function handleEditAndResend(message: Message) { + if (!workspaceId || !conversationId || !message.clientId) return; + removeOptimisticMessage( + queryClient, + queryKeys.ws.messages.list(workspaceId, conversationId), + message.clientId, + ); + setDraft(conversationId, message.text ?? ''); + actionsMenuRef.current?.close(); + } + + if (!workspaceId || !conversationId) { + return <Screen />; + } + + const normalizedError = messagesQuery.error ? normalizeApiError(messagesQuery.error) : null; + if (normalizedError?.kind === 'workspaceBlocked') { + return ( + <Screen edges={['top']}> + <WorkspaceBlockedGate reason={normalizedError.reason} message={normalizedError.message} /> + </Screen> + ); + } + + function handleHeaderLayout(event: LayoutChangeEvent) { + setHeaderHeight(event.nativeEvent.layout.height); + } + + return ( + <Screen edges={['top']}> + <ChatHeader + workspaceId={workspaceId} + conversationId={conversationId} + onBack={() => router.back()} + onLayout={handleHeaderLayout} + /> + <ConnectionBanner state={connectionState} /> + <KeyboardAvoidingView + style={{ flex: 1 }} + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + keyboardVerticalOffset={headerHeight + insets.top} + > + {messagesQuery.isError ? ( + <ErrorBanner + message={describeApiError(messagesQuery.error, t)} + actionLabel={t('common.retry')} + onAction={() => messagesQuery.refetch()} + /> + ) : null} + <MessageList + ref={messageListRef} + messages={chronologicalMessages} + isLoading={messagesQuery.isPending} + hasNextPage={Boolean(messagesQuery.hasNextPage)} + isFetchingNextPage={messagesQuery.isFetchingNextPage} + conversationId={conversationId} + justSentMessageId={justSentMessageId} + onLoadOlder={() => messagesQuery.fetchNextPage()} + onLongPressMessage={handleLongPress} + onRetryMessage={handleRetry} + /> + <Composer + workspaceId={workspaceId} + conversationId={conversationId} + style={isKeyboardVisible ? undefined : { paddingBottom: insets.bottom }} + /> + </KeyboardAvoidingView> + + <MessageActionsMenu + ref={actionsMenuRef} + message={selectedMessage} + onEdit={(message) => { + setComposerMode(conversationId, { type: 'edit', message }); + setDraft(conversationId, message.text ?? ''); + actionsMenuRef.current?.close(); + }} + onReply={(message) => { + setComposerMode(conversationId, { type: 'reply', message }); + actionsMenuRef.current?.close(); + }} + onDelete={(message) => { + deleteMessage.mutate({ messageId: message.id, createdAt: message.createdAt }); + actionsMenuRef.current?.close(); + }} + onLike={(message) => { + setAttributes.mutate({ + messageId: message.id, + createdAt: message.createdAt, + liked: true, + }); + actionsMenuRef.current?.close(); + }} + onHide={(message) => { + setAttributes.mutate({ + messageId: message.id, + createdAt: message.createdAt, + hidden: true, + }); + actionsMenuRef.current?.close(); + }} + onEditAndResend={handleEditAndResend} + /> + </Screen> + ); +} diff --git a/src/app/(app)/profile.tsx b/src/app/(app)/profile.tsx new file mode 100644 index 0000000..b80fed0 --- /dev/null +++ b/src/app/(app)/profile.tsx @@ -0,0 +1,36 @@ +import { View } from 'react-native'; + +import { Avatar } from '@/components/ui/avatar'; +import { ListItem } from '@/components/ui/list-item'; +import { Screen } from '@/components/ui/screen'; +import { Text } from '@/components/ui/text'; +import { useSignOut } from '@/features/auth/use-sign-out'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useTheme } from '@/theme/use-theme'; +import { useTranslation } from 'react-i18next'; + +/** Profile modal: identity + sign-out. Dark-mode/language live on the settings tab (this modal + * doesn't duplicate them) since there's no plan/billing API yet to justify a separate "account" + * surface beyond identity. */ +export default function ProfileScreen() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const user = useAuthStore((state) => state.user); + const signOut = useSignOut(); + + return ( + <Screen padded> + <View style={{ alignItems: 'center', gap: spacing.sm, marginBottom: spacing.lg }}> + <Avatar name={user?.name ?? user?.email ?? '?'} size={72} /> + <Text variant="title">{user?.name ?? user?.email}</Text> + {user?.name ? ( + <Text variant="caption" color="secondary"> + {user.email} + </Text> + ) : null} + </View> + + <ListItem title={t('auth.signOut')} onPress={signOut} destructive /> + </Screen> + ); +} diff --git a/src/app/(app)/settings/members.tsx b/src/app/(app)/settings/members.tsx new file mode 100644 index 0000000..15c78d1 --- /dev/null +++ b/src/app/(app)/settings/members.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from 'react-i18next'; +import { ScrollView, View } from 'react-native'; + +import { describeApiError } from '@/lib/describe-api-error'; +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { ListItem } from '@/components/ui/list-item'; +import { Screen } from '@/components/ui/screen'; +import { SectionHeader } from '@/components/ui/section-header'; +import { SkeletonRow } from '@/components/ui/skeleton'; +import { useTeamsList } from '@/features/settings/api/use-teams'; +import { useWorkspaceMembersList } from '@/features/permissions/use-permissions'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +/** Read-only members + teams screen. Teams is enterprise-tier and gracefully hides + * itself on any fetch error (see use-teams.ts) rather than surfacing a broken state for + * workspaces without the feature. */ +export default function MembersScreen() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId) ?? ''; + + const membersQuery = useWorkspaceMembersList(workspaceId); + const teamsQuery = useTeamsList(workspaceId); + + const members = membersQuery.data ?? []; + const teams = teamsQuery.data ?? []; + const showTeamsSection = !teamsQuery.isError && (teamsQuery.isPending || teams.length > 0); + + return ( + <Screen> + <ScrollView> + <SectionHeader title={t('settings.members')} /> + {membersQuery.isPending ? ( + <View> + <SkeletonRow.Conversation /> + <SkeletonRow.Conversation /> + <SkeletonRow.Conversation /> + </View> + ) : membersQuery.isError ? ( + <View style={{ paddingHorizontal: spacing.md }}> + <ErrorBanner + message={describeApiError(membersQuery.error, t)} + actionLabel={t('common.retry')} + onAction={() => membersQuery.refetch()} + /> + </View> + ) : members.length === 0 ? ( + <EmptyState icon="people-outline" title={t('settings.noMembers')} /> + ) : ( + members.map((member) => ( + <ListItem + key={member.id} + title={member.user.name ?? t('contacts.unknown')} + leading={<Avatar uri={member.user.image} name={member.user.name ?? '?'} />} + trailing={ + <Badge> + {member.role === 'owner' ? t('settings.roleOwner') : t('settings.roleAgent')} + </Badge> + } + /> + )) + )} + + {showTeamsSection ? ( + <> + <SectionHeader title={t('settings.teams')} /> + {teamsQuery.isPending ? ( + <View> + <SkeletonRow.Conversation /> + </View> + ) : teams.length === 0 ? ( + <EmptyState icon="people-circle-outline" title={t('settings.noTeams')} /> + ) : ( + teams.map((team) => ( + <ListItem + key={team.id} + title={team.name} + subtitle={`${team.inboxTeamMembers.length}`} + /> + )) + )} + </> + ) : null} + </ScrollView> + </Screen> + ); +} diff --git a/src/app/(app)/workspace-picker.tsx b/src/app/(app)/workspace-picker.tsx new file mode 100644 index 0000000..922fc84 --- /dev/null +++ b/src/app/(app)/workspace-picker.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from 'react-i18next'; +import { FlatList, RefreshControl, View } from 'react-native'; + +import { describeApiError } from '@/lib/describe-api-error'; +import { Card } from '@/components/ui/surface'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Screen } from '@/components/ui/screen'; +import { SkeletonRow } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useSwitchWorkspace } from '@/features/workspaces/api/use-switch-workspace'; +import { useWorkspaces, type Workspace } from '@/features/workspaces/api/use-workspaces'; +import { WorkspaceRow } from '@/features/workspaces/workspace-row'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +export default function WorkspacePickerScreen() { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { data: workspaces, isPending, isError, isRefetching, error, refetch } = useWorkspaces(); + const switchWorkspace = useSwitchWorkspace(); + + function handleSelect(workspace: Workspace) { + switchWorkspace(workspace.id, { navigate: true }); + } + + return ( + <Screen padded edges={['top']}> + <Text variant="display" style={{ marginBottom: spacing.md }}> + {t('workspaces.title')} + </Text> + + {isPending ? ( + <View style={{ gap: spacing.sm }}> + <SkeletonRow.Conversation /> + <SkeletonRow.Conversation /> + <SkeletonRow.Conversation /> + </View> + ) : isError ? ( + <ErrorBanner + message={describeApiError(error, t)} + actionLabel={t('common.retry')} + onAction={() => refetch()} + /> + ) : workspaces && workspaces.length > 0 ? ( + <FlatList + data={workspaces} + keyExtractor={(item) => item.id} + contentContainerStyle={{ gap: spacing.sm }} + renderItem={({ item }) => ( + <Card> + <WorkspaceRow + workspace={item} + selected={item.id === currentWorkspaceId} + onPress={handleSelect} + /> + </Card> + )} + refreshControl={ + <RefreshControl + refreshing={isRefetching} + onRefresh={() => refetch()} + tintColor={colors.brand} + /> + } + /> + ) : ( + <EmptyState icon="business-outline" title={t('workspaces.empty')} /> + )} + </Screen> + ); +} diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx new file mode 100644 index 0000000..cadd5e9 --- /dev/null +++ b/src/app/(auth)/_layout.tsx @@ -0,0 +1,16 @@ +import { Redirect, Stack } from 'expo-router'; + +import { useAuthStore } from '@/stores/use-auth-store'; + +/** Auth route group: signed-in users never see these screens (redirect to `/`, whose own + * redirect logic sends them to the tabs shell or mustChangePassword as appropriate). */ +export default function AuthLayout() { + const status = useAuthStore((state) => state.status); + const mustChangePassword = useAuthStore((state) => state.user?.mustChangePassword ?? false); + + if (status === 'signed-in' && !mustChangePassword) { + return <Redirect href="/" />; + } + + return <Stack screenOptions={{ headerShown: false }} />; +} diff --git a/src/app/(auth)/change-password.tsx b/src/app/(auth)/change-password.tsx new file mode 100644 index 0000000..9506147 --- /dev/null +++ b/src/app/(auth)/change-password.tsx @@ -0,0 +1,135 @@ +import { router } from 'expo-router'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TextInput } from 'react-native'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; + +import { changePassword } from '@/api/auth-endpoints'; +import { getAuthToken } from '@/api/auth-token'; +import { Button } from '@/components/ui/button'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Screen } from '@/components/ui/screen'; +import { Text } from '@/components/ui/text'; +import { TextField } from '@/components/ui/text-field'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Forced password-change screen — reached when the session user has `mustChangePassword: true` + * (reseller-provisioned temporary password). Every other oRPC/session-authenticated call is + * blocked server-side until this completes, so this screen has no way out except a successful + * change. + */ +export default function ChangePasswordScreen() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const setMustChangePassword = useAuthStore((state) => state.setMustChangePassword); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [isCurrentPasswordVisible, setIsCurrentPasswordVisible] = useState(false); + const [isNewPasswordVisible, setIsNewPasswordVisible] = useState(false); + const [error, setError] = useState<string | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const newPasswordInputRef = useRef<TextInput>(null); + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + try { + const token = await getAuthToken(); + if (!token) { + throw new Error(t('errors.unauthorized')); + } + await changePassword(token, currentPassword, newPassword); + setMustChangePassword(false); + router.replace('/'); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.unknown')); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = currentPassword.length > 0 && newPassword.length >= 8 && !isSubmitting; + + return ( + <Screen> + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + style={styles.flex} + > + <ScrollView + contentContainerStyle={[styles.scrollContent, { padding: spacing.lg }]} + keyboardShouldPersistTaps="handled" + > + <View style={[styles.form, { gap: spacing.md }]}> + <Text variant="heading">{t('auth.changePassword')}</Text> + <Text variant="body" color="secondary"> + {t('auth.changePasswordRequired')} + </Text> + + {error ? <ErrorBanner message={error} /> : null} + + <TextField + label={t('auth.currentPassword')} + value={currentPassword} + onChangeText={setCurrentPassword} + secureTextEntry={!isCurrentPasswordVisible} + autoCapitalize="none" + textContentType="password" + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => newPasswordInputRef.current?.focus()} + trailingIcon={isCurrentPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIconAccessibilityLabel={ + isCurrentPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') + } + onTrailingIconPress={() => setIsCurrentPasswordVisible((visible) => !visible)} + /> + <TextField + ref={newPasswordInputRef} + label={t('auth.newPassword')} + value={newPassword} + onChangeText={setNewPassword} + secureTextEntry={!isNewPasswordVisible} + autoCapitalize="none" + textContentType="newPassword" + returnKeyType="done" + blurOnSubmit + onSubmitEditing={handleSubmit} + trailingIcon={isNewPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIconAccessibilityLabel={ + isNewPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') + } + onTrailingIconPress={() => setIsNewPasswordVisible((visible) => !visible)} + /> + + <Button + label={t('auth.changePassword')} + onPress={handleSubmit} + loading={isSubmitting} + disabled={!canSubmit} + size="lg" + fullWidth + /> + </View> + </ScrollView> + </KeyboardAvoidingView> + </Screen> + ); +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + }, + form: { + width: '100%', + }, +}); diff --git a/src/app/(auth)/forgot-password.tsx b/src/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..f075db3 --- /dev/null +++ b/src/app/(auth)/forgot-password.tsx @@ -0,0 +1,106 @@ +import { router } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; + +import { requestPasswordReset } from '@/api/auth-endpoints'; +import { Button } from '@/components/ui/button'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Screen } from '@/components/ui/screen'; +import { Text } from '@/components/ui/text'; +import { TextField } from '@/components/ui/text-field'; +import { useTheme } from '@/theme/use-theme'; + +export default function ForgotPasswordScreen() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + + const [email, setEmail] = useState(''); + const [error, setError] = useState<string | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSent, setIsSent] = useState(false); + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + try { + await requestPasswordReset(email.trim()); + setIsSent(true); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.unknown')); + } finally { + setIsSubmitting(false); + } + }; + + if (isSent) { + return ( + <Screen padded> + <EmptyState + icon="checkmark-circle" + title={t('auth.forgotPassword')} + description={t('auth.resetLinkSent')} + action={{ label: t('auth.backToSignIn'), onPress: () => router.back() }} + /> + </Screen> + ); + } + + return ( + <Screen> + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + style={styles.flex} + > + <ScrollView + contentContainerStyle={[styles.scrollContent, { padding: spacing.lg }]} + keyboardShouldPersistTaps="handled" + > + <View style={[styles.form, { gap: spacing.md }]}> + <Text variant="heading">{t('auth.forgotPassword')}</Text> + <Text variant="body" color="secondary"> + {t('auth.forgotPasswordSubtitle')} + </Text> + + {error ? <ErrorBanner message={error} /> : null} + + <TextField + label={t('auth.email')} + value={email} + onChangeText={setEmail} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + textContentType="emailAddress" + returnKeyType="go" + blurOnSubmit + onSubmitEditing={handleSubmit} + /> + <Button + label={t('auth.sendResetLink')} + onPress={handleSubmit} + loading={isSubmitting} + disabled={email.trim().length === 0 || isSubmitting} + size="lg" + fullWidth + /> + </View> + </ScrollView> + </KeyboardAvoidingView> + </Screen> + ); +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + }, + form: { + width: '100%', + }, +}); diff --git a/src/app/(auth)/sign-in.tsx b/src/app/(auth)/sign-in.tsx new file mode 100644 index 0000000..81978c7 --- /dev/null +++ b/src/app/(auth)/sign-in.tsx @@ -0,0 +1,194 @@ +import { Link, router, type Href } from 'expo-router'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TextInput } from 'react-native'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { signInWithEmail } from '@/api/auth-endpoints'; +import { Button } from '@/components/ui/button'; +import { Divider } from '@/components/ui/divider'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Icon } from '@/components/ui/icon'; +import { Screen } from '@/components/ui/screen'; +import { Text } from '@/components/ui/text'; +import { TextField } from '@/components/ui/text-field'; +import { SocialButtons } from '@/features/auth/social-buttons'; +import { consumePendingDeepLink } from '@/lib/pending-deep-link'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +const LOGO_SIZE = 72; + +export default function SignInScreen() { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const reducedMotion = useReducedMotion(); + const setSignedIn = useAuthStore((state) => state.setSignedIn); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [error, setError] = useState<string | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const passwordInputRef = useRef<TextInput>(null); + + const handleSubmit = async () => { + setError(null); + setIsSubmitting(true); + try { + const user = await signInWithEmail(email.trim(), password); + setSignedIn(user); + // If the app was cold-started via a `/conversations/:id` deep link (e.g. a push + // notification, once delivery is wired up) while signed out, return there instead of the + // default post-auth redirect — see lib/pending-deep-link.ts. A mustChangePassword user is + // still routed to '/' regardless (its own guard traps them in change-password first). + const pendingDeepLink = user.mustChangePassword ? null : consumePendingDeepLink(); + // A captured deep link is an arbitrary runtime string, not a route this app's typed-routes + // manifest can know statically — `Href` (rather than `as never`) is expo-router's own type + // for exactly this "any valid path string" case. + router.replace((pendingDeepLink ?? '/') as Href); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.unknown')); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = email.trim().length > 0 && password.length > 0 && !isSubmitting; + + return ( + <Screen> + <KeyboardAvoidingView + behavior={Platform.OS === 'ios' ? 'padding' : 'height'} + style={styles.flex} + > + <ScrollView + contentContainerStyle={[styles.scrollContent, { padding: spacing.lg }]} + keyboardShouldPersistTaps="handled" + > + <View style={[styles.form, { gap: spacing.md }]}> + <Animated.View + entering={reducedMotion ? undefined : FadeInUp.duration(400)} + style={[styles.logoWrap, { marginBottom: spacing.sm }]} + > + <View + style={[ + styles.logoCircle, + { backgroundColor: colors.brandSoft, borderRadius: LOGO_SIZE / 2 }, + ]} + > + <Icon name="chatbubbles" size={32} color={colors.brand} /> + </View> + </Animated.View> + + <Text variant="display" style={styles.centered}> + {t('auth.signIn')} + </Text> + <Text variant="body" color="secondary" style={styles.centered}> + {t('auth.signInSubtitle')} + </Text> + + {error ? <ErrorBanner message={error} /> : null} + + <TextField + label={t('auth.email')} + value={email} + onChangeText={setEmail} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + textContentType="emailAddress" + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => passwordInputRef.current?.focus()} + /> + <TextField + ref={passwordInputRef} + label={t('auth.password')} + value={password} + onChangeText={setPassword} + secureTextEntry={!isPasswordVisible} + autoCapitalize="none" + autoComplete="password" + textContentType="password" + returnKeyType="go" + blurOnSubmit + onSubmitEditing={handleSubmit} + trailingIcon={isPasswordVisible ? 'eye-off-outline' : 'eye-outline'} + trailingIconAccessibilityLabel={ + isPasswordVisible ? t('auth.hidePassword') : t('auth.showPassword') + } + onTrailingIconPress={() => setIsPasswordVisible((visible) => !visible)} + /> + + <Button + label={t('auth.signIn')} + onPress={handleSubmit} + loading={isSubmitting} + disabled={!canSubmit} + size="lg" + fullWidth + /> + + <Link href="/(auth)/forgot-password" style={styles.link}> + <Text variant="callout" color="brand"> + {t('auth.forgotPassword')} + </Text> + </Link> + + <View style={[styles.dividerRow, { gap: spacing.sm }]}> + <View style={styles.dividerLine}> + <Divider /> + </View> + <Text variant="caption" color="secondary"> + {t('auth.orContinueWith')} + </Text> + <View style={styles.dividerLine}> + <Divider /> + </View> + </View> + <SocialButtons /> + </View> + </ScrollView> + </KeyboardAvoidingView> + </Screen> + ); +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + }, + form: { + width: '100%', + }, + logoWrap: { + alignItems: 'center', + }, + logoCircle: { + width: LOGO_SIZE, + height: LOGO_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + link: { + alignSelf: 'center', + }, + dividerRow: { + flexDirection: 'row', + alignItems: 'center', + }, + dividerLine: { + flex: 1, + }, + centered: { + textAlign: 'center', + }, +}); diff --git a/src/app/(dev)/gallery.tsx b/src/app/(dev)/gallery.tsx new file mode 100644 index 0000000..eb4f7cf --- /dev/null +++ b/src/app/(dev)/gallery.tsx @@ -0,0 +1,19 @@ +import { Redirect } from 'expo-router'; + +/** + * Dev-build-only "storybook-lite" route — see `src/dev/gallery-content.tsx` for the actual + * screen. That module is `require()`d lazily, inside the `__DEV__` branch, rather than imported + * statically at the top of this file: its 24 UI-primitive imports would otherwise ship in every + * production bundle even though the screen itself redirects away immediately outside `__DEV__`. + * The route file itself stays registered unconditionally so expo-router's typed routes keep + * resolving `/gallery` in every build. + */ +export default function GalleryScreen() { + if (!__DEV__) { + return <Redirect href="/(app)/(tabs)/conversations" />; + } + + // eslint-disable-next-line @typescript-eslint/no-require-imports -- intentionally lazy, see above + const { GalleryContent } = require('@/dev/gallery-content'); + return <GalleryContent />; +} diff --git a/src/app/+not-found.tsx b/src/app/+not-found.tsx new file mode 100644 index 0000000..4d543d1 --- /dev/null +++ b/src/app/+not-found.tsx @@ -0,0 +1,29 @@ +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { Screen } from '@/components/ui/screen'; +import { useAuthStore } from '@/stores/use-auth-store'; + +/** Catch-all for unmatched routes (bad deep link, stale link, typo'd path). Routes back to the + * conversations tab if signed in, or sign-in otherwise — mirrors index.tsx's own redirect logic + * rather than assuming an authed session. */ +export default function NotFoundScreen() { + const { t } = useTranslation(); + const status = useAuthStore((state) => state.status); + + function handleGoHome() { + router.replace(status === 'signed-in' ? '/(app)/(tabs)/conversations' : '/(auth)/sign-in'); + } + + return ( + <Screen padded> + <EmptyState + icon="compass-outline" + title={t('errors.notFoundTitle')} + description={t('errors.notFoundBody')} + action={{ label: t('errors.goHome'), onPress: handleGoHome }} + /> + </Screen> + ); +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx new file mode 100644 index 0000000..d28ef41 --- /dev/null +++ b/src/app/_layout.tsx @@ -0,0 +1,224 @@ +import '@/lib/install-crypto-polyfill'; + +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { + PlusJakartaSans_600SemiBold, + PlusJakartaSans_700Bold, + useFonts, +} from '@expo-google-fonts/plus-jakarta-sans'; +import type { ErrorBoundaryProps } from 'expo-router'; +import { Stack, ThemeProvider } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; +import { StatusBar } from 'expo-status-bar'; +import * as SystemUI from 'expo-system-ui'; +import { useEffect, useState } from 'react'; +import { I18nManager } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { useTranslation } from 'react-i18next'; + +import { getSession } from '@/api/auth-endpoints'; +import { clearAuthToken, getAuthToken } from '@/api/auth-token'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ToastProvider } from '@/components/ui/toast'; +import { initI18n } from '@/i18n'; +import { applyLanguage } from '@/i18n/apply-language'; +import { reconcileRTLOnLaunch } from '@/i18n/reconcile-rtl'; +import { queryClient } from '@/lib/query-client'; +import { capturePendingDeepLink } from '@/lib/pending-deep-link'; +// Importing this (rather than only from Settings, lazily, as before) registers +// `Notifications.setNotificationHandler` unconditionally at app start — see notifications.ts. +import { ensureAndroidNotificationChannel } from '@/lib/notifications'; +import { initNotificationTapHandling } from '@/lib/notification-tap'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useSettingsStore, waitForSettingsHydration } from '@/stores/use-settings-store'; +import { waitForWorkspaceHydration } from '@/stores/use-workspace-store'; +import { getNavigationTheme } from '@/theme/navigation-theme'; +import { useResolvedScheme, useTheme } from '@/theme/use-theme'; + +SplashScreen.preventAutoHideAsync().catch(() => { + // Only fails if the splash screen was already hidden or never registered — either way there's + // nothing to recover, and the bootstrap gate below still hides it once ready. +}); +SplashScreen.setOptions({ duration: 300, fade: true }); + +// i18next resources are static/local (bundled JSON, no network) and init() is synchronous, so +// this runs once at module load — before the first render — rather than as a render-triggered +// effect. That avoids the extra render pass a `useState` + `useEffect` combo would cause. +initI18n(); +// Allows the OS/dev-build shell to lay out RTL before any native view mounts; actually flipping +// direction still requires `forceRTL` + a reload — see reconcile-rtl.ts. +I18nManager.allowRTL(true); +// Must run before the auth guard's first redirect can fire — see pending-deep-link.ts. +capturePendingDeepLink(); + +/** + * Bootstrap gate: waits for the persisted settings store to hydrate, then applies the persisted + * language to i18next/dayjs and reconciles native RTL direction against it — before the splash + * screen can hide. Module-load `initI18n()` above already picked a device-resolved language + * synchronously; this corrects it to the user's actual saved preference once hydration completes. + */ +function useLanguageBootstrap(): boolean { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function bootstrap() { + await waitForSettingsHydration(); + const language = useSettingsStore.getState().language; + await applyLanguage(language); + await reconcileRTLOnLaunch(language); + } + + bootstrap().finally(() => { + if (!cancelled) setReady(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + return ready; +} + +/** + * Bootstrap gate: resolves the auth store's 'pending' status to 'signed-in'/'signed-out' by + * checking for a stored token and validating it against `getSession`, then hides the splash + * screen. Also waits for the persisted workspace-store snapshot to hydrate, so a cold start never + * reads `currentWorkspaceId === null` before the real (persisted) value has loaded and bounces an + * already-workspace-selected user to the picker. Runs once at root-layout mount — every route's + * own guard reads `status` off the store rather than re-checking the session itself. + */ +function useAuthBootstrap(): boolean { + const [ready, setReady] = useState(false); + const setSignedIn = useAuthStore((state) => state.setSignedIn); + const setSignedOut = useAuthStore((state) => state.setSignedOut); + + useEffect(() => { + let cancelled = false; + + async function bootstrap() { + await waitForWorkspaceHydration(); + + const token = await getAuthToken(); + if (!token) { + if (!cancelled) setSignedOut(); + return; + } + + try { + const user = await getSession(token); + if (cancelled) return; + + if (user) { + setSignedIn(user); + } else { + await clearAuthToken(); + setSignedOut(); + } + } catch { + // A network/server failure validating the session is not proof the token is invalid — + // treat it as signed-out for this launch without clearing SecureStore, so the next + // successful bootstrap can still sign the user back in with the same token. + if (!cancelled) setSignedOut(); + } + } + + bootstrap().finally(() => { + if (!cancelled) setReady(true); + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once at mount; store setters are stable. + }, []); + + return ready; +} + +/** + * Provider stack (outer → inner): gesture handler root (required once at the app root by + * react-native-gesture-handler) > QueryClientProvider > BottomSheetModalProvider (so any screen + * can open a modal bottom sheet without its own provider) > ThemeProvider (our tokens, adapted for + * react-navigation via navigation-theme.ts). The auth bootstrap gate holds the splash screen until + * session status resolves, before any route's own guard runs. + */ +export default function RootLayout() { + const scheme = useResolvedScheme(); + const { colors } = useTheme(); + const authReady = useAuthBootstrap(); + const languageReady = useLanguageBootstrap(); + const [fontsLoaded, fontError] = useFonts({ + PlusJakartaSans_600SemiBold, + PlusJakartaSans_700Bold, + }); + + useEffect(() => { + // A font failing to load (e.g. offline on first launch before assets are cached) must not + // hold the splash screen forever — proceed with the system fallback font instead. + if (authReady && languageReady && (fontsLoaded || fontError)) { + SplashScreen.hideAsync(); + } + }, [authReady, languageReady, fontsLoaded, fontError]); + + // Registers the Android notification channel (idempotent — safe to call again even though + // getPushTokenAsync also calls this lazily) and wires notification-tap → navigation, once auth + // bootstrap has resolved. `initNotificationTapHandling` itself no-ops the tap navigation when + // `useAuthStore` isn't 'signed-in' (see notification-tap.ts), so gating on `authReady` here is + // about giving `getLastNotificationResponseAsync` a resolved auth state to check against for the + // cold-start case, not a hard requirement for the listener registration itself. + useEffect(() => { + if (!authReady) return undefined; + ensureAndroidNotificationChannel(); + return initNotificationTapHandling(); + }, [authReady]); + + // Android only — iOS has no themable window/root background to set here, and + // `expo-system-ui` no-ops harmlessly on iOS/web. + useEffect(() => { + SystemUI.setBackgroundColorAsync(colors.bg); + }, [colors.bg]); + + return ( + <GestureHandlerRootView style={{ flex: 1 }}> + <QueryClientProvider client={queryClient}> + <BottomSheetModalProvider> + <ThemeProvider value={getNavigationTheme(scheme)}> + <ToastProvider> + <StatusBar style={scheme === 'dark' ? 'light' : 'dark'} /> + <Stack screenOptions={{ headerShown: false }}> + <Stack.Screen name="camera" options={{ presentation: 'fullScreenModal' }} /> + <Stack.Screen + name="image-viewer" + options={{ + presentation: 'fullScreenModal', + animation: 'fade', + contentStyle: { backgroundColor: 'transparent' }, + }} + /> + </Stack> + </ToastProvider> + </ThemeProvider> + </BottomSheetModalProvider> + </QueryClientProvider> + </GestureHandlerRootView> + ); +} + +/** expo-router renders this in place of the whole navigation tree when a route throws during + * render — without it, an uncaught error white-screens the app with no way back in. */ +export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) { + const { t } = useTranslation(); + + return ( + <EmptyState + icon="warning-outline" + title={t('common.error')} + description={error.message || t('errors.unknown')} + action={{ label: t('common.retry'), onPress: retry }} + /> + ); +} diff --git a/src/app/camera.tsx b/src/app/camera.tsx new file mode 100644 index 0000000..278f20c --- /dev/null +++ b/src/app/camera.tsx @@ -0,0 +1,176 @@ +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { File } from 'expo-file-system'; +import { router, useLocalSearchParams } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Screen } from '@/components/ui/screen'; +import { Text } from '@/components/ui/text'; +import { + AttachmentTooLargeError, + MAX_FILE_SIZE_BYTES, + useSendMessage, +} from '@/features/chat/api/use-send-message'; +import { triggerHaptic } from '@/lib/haptics'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useTheme } from '@/theme/use-theme'; + +/** Root-level route (outside the (app) group, per the plan's route tree) with a fullScreenModal + * presentation declared on its Stack.Screen in src/app/_layout.tsx. Captures a photo and sends it + * directly as an attachment message on the given conversation, rather than returning to the + * composer for a second step. */ +export default function CameraScreen() { + const { t } = useTranslation(); + const { conversationId } = useLocalSearchParams<{ conversationId: string }>(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { colors, spacing } = useTheme(); + const insets = useSafeAreaInsets(); + const [permission, requestPermission] = useCameraPermissions(); + const cameraRef = useRef<CameraView>(null); + const [isCapturing, setIsCapturing] = useState(false); + const [error, setError] = useState<string | null>(null); + const sendMessage = useSendMessage(workspaceId ?? '', conversationId ?? ''); + + if (!permission) { + return <Screen />; + } + + if (!permission.granted) { + return ( + <Screen padded style={styles.centered}> + <Text variant="body" style={styles.centered}> + {t('chat.grantCameraPermission')} + </Text> + <Button label={t('chat.grantPermission')} onPress={requestPermission} /> + </Screen> + ); + } + + async function handleCapture() { + if (!cameraRef.current || !workspaceId || !conversationId) return; + triggerHaptic('light'); + setIsCapturing(true); + setError(null); + + try { + const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 }); + if (!photo?.uri) return; + + // SDK 57 replaced the legacy `getInfoAsync` API with a class-based `File` — `new + // File(uri).size` is the current way to stat a local file (see + // https://docs.expo.dev/versions/v57.0.0/sdk/filesystem/). Guard BEFORE attempting to send, + // per the plan — previously this screen relied entirely on the backend's own size rejection + // with no client-side check at all. + const fileSize = new File(photo.uri).size; + if (typeof fileSize === 'number' && fileSize > MAX_FILE_SIZE_BYTES) { + setError(t('chat.attachmentTooLarge', { fileName: 'photo.jpg' })); + return; + } + + await sendMessage.mutateAsync({ + workspaceId, + conversationId, + attachments: [ + { uri: photo.uri, mimeType: 'image/jpeg', fileName: `photo-${Date.now()}.jpg` }, + ], + }); + triggerHaptic('success'); + router.back(); + } catch (err) { + triggerHaptic('error'); + setError(err instanceof AttachmentTooLargeError ? err.message : t('chat.failedToSendPhoto')); + } finally { + setIsCapturing(false); + } + } + + return ( + <View style={styles.container}> + <StatusBar style="light" /> + <CameraView ref={cameraRef} style={styles.camera} facing="back" /> + <View style={[styles.controls, { padding: spacing.lg }]}> + <Pressable + accessibilityRole="button" + accessibilityLabel={t('common.close', { defaultValue: 'Close' })} + onPress={() => router.back()} + style={styles.closeButton} + > + <Icon name="close" size={28} color={colors.textInverse} /> + </Pressable> + <Pressable + accessibilityRole="button" + accessibilityLabel={t('chat.camera')} + onPress={handleCapture} + disabled={isCapturing} + style={[ + styles.captureButton, + { borderColor: colors.textInverse, opacity: isCapturing ? 0.6 : 1 }, + ]} + > + {isCapturing ? <ActivityIndicator color={colors.textPrimary} /> : null} + </Pressable> + </View> + {error ? ( + <View + style={[ + styles.errorBanner, + { backgroundColor: colors.danger, top: insets.top + spacing.lg }, + ]} + > + <Text style={{ color: colors.textInverse }}>{error}</Text> + </View> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000000', + }, + camera: { + flex: 1, + }, + controls: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + closeButton: { + padding: 8, + }, + captureButton: { + width: 68, + height: 68, + borderRadius: 34, + borderWidth: 4, + backgroundColor: '#ffffff', + alignSelf: 'center', + marginHorizontal: 'auto', + alignItems: 'center', + justifyContent: 'center', + }, + errorBanner: { + position: 'absolute', + left: 16, + right: 16, + padding: 12, + borderRadius: 8, + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, +}); diff --git a/src/app/image-viewer.tsx b/src/app/image-viewer.tsx new file mode 100644 index 0000000..0083bfa --- /dev/null +++ b/src/app/image-viewer.tsx @@ -0,0 +1,50 @@ +import { Image } from 'expo-image'; +import { router, useLocalSearchParams } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/ui/icon'; +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +/** Root-level route, transparent fullScreenModal presentation (declared on its Stack.Screen in + * src/app/_layout.tsx). Simple full-screen image + close button — pinch-zoom is skipped, it would + * need a gesture-handler-driven zoom wrapper that's not worth the complexity for this phase. + * Background is a near-opaque scrim over black (not `colors.scrim`, which is tuned at 0.5 alpha + * for overlays atop app content — this route wants the image to read as the sole subject, closer + * to opaque) so it stays consistent regardless of the active theme scheme. */ +export default function ImageViewerScreen() { + const { url } = useLocalSearchParams<{ url: string }>(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + <View style={[styles.container, { backgroundColor: withAlpha('#000000', 0.92) }]}> + <StatusBar style="light" /> + <Pressable + accessibilityRole="button" + onPress={() => router.back()} + style={[styles.closeButton, { top: insets.top + 8 }]} + > + <Icon name="close" size={28} color={colors.textInverse} /> + </Pressable> + {url ? <Image source={{ uri: url }} style={styles.image} contentFit="contain" /> : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + image: { + flex: 1, + }, + closeButton: { + position: 'absolute', + end: 16, + zIndex: 1, + padding: 8, + }, +}); diff --git a/src/app/index.tsx b/src/app/index.tsx new file mode 100644 index 0000000..5603edd --- /dev/null +++ b/src/app/index.tsx @@ -0,0 +1,102 @@ +import { Redirect } from 'expo-router'; +import { useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +import { Icon } from '@/components/ui/icon'; +import { Screen } from '@/components/ui/screen'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +const LOGO_SIZE = 72; +const PULSE_DURATION_MS = 900; + +/** + * Root redirect gate: !authed → sign-in; mustChangePassword → change-password; no workspace → + * workspace-picker; else → tabs shell. + */ +export default function IndexRedirect() { + const status = useAuthStore((state) => state.status); + const mustChangePassword = useAuthStore((state) => state.user?.mustChangePassword ?? false); + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + if (status === 'pending') { + return <LoadingScreen />; + } + + if (status === 'signed-out') { + return <Redirect href="/(auth)/sign-in" />; + } + + if (mustChangePassword) { + return <Redirect href="/(auth)/change-password" />; + } + + if (!currentWorkspaceId) { + return <Redirect href="/(app)/workspace-picker" />; + } + + return <Redirect href="/(app)/(tabs)/conversations" />; +} + +/** Brand mark with a gentle pulse instead of a bare spinner — this screen normally shows only for + * the brief window between the splash screen hiding and the auth/workspace bootstrap gates + * resolving, so it's the first thing a returning user sees post-splash. */ +function LoadingScreen() { + const { colors } = useTheme(); + const reducedMotion = useReducedMotion(); + const scale = useSharedValue(1); + + useEffect(() => { + if (reducedMotion) { + scale.value = 1; + return undefined; + } + scale.value = withRepeat( + withTiming(1.08, { duration: PULSE_DURATION_MS, easing: Easing.inOut(Easing.ease) }), + -1, + true, + ); + return undefined; + }, [reducedMotion, scale]); + + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + + return ( + <Screen> + <View style={styles.center}> + <Animated.View + style={[ + styles.logoCircle, + { backgroundColor: colors.brandSoft, borderRadius: LOGO_SIZE / 2 }, + animatedStyle, + ]} + > + <Icon name="chatbubbles" size={32} color={colors.brand} /> + </Animated.View> + </View> + </Screen> + ); +} + +const styles = StyleSheet.create({ + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + logoCircle: { + width: LOGO_SIZE, + height: LOGO_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..fd7b3a6 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,111 @@ +import { Image } from 'expo-image'; +import type { ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +type Ring = 'none' | 'online' | 'bot'; + +interface AvatarProps { + uri?: string | null; + name: string; + size?: number; + /** Slot anchored bottom-end (channel icon, status dot). Absolutely positioned over the avatar. */ + badge?: ReactNode; + /** Colored ring around the avatar: 'online' (success) or 'bot' (violet accent). */ + ring?: Ring; +} + +function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); +} + +/** Deterministic hash of a string into a small non-negative int — used to pick a stable + * avatar-palette index per name/id, so the same contact always gets the same fallback tint. */ +function hashString(value: string): number { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; // deliberate 32-bit hash mixing + } + return Math.abs(hash); +} + +export function Avatar({ uri, name, size = 40, badge, ring = 'none' }: AvatarProps) { + const { colors } = useTheme(); + const dimensionStyle = { width: size, height: size, borderRadius: size / 2 }; + const ringColor = + ring === 'online' ? colors.success : ring === 'bot' ? colors.bubbleBotAccent : undefined; + const ringWidth = size < 32 ? 1.5 : 2; + + const paletteIndex = hashString(name) % colors.avatarPalette.length; + const fallbackColor = colors.avatarPalette[paletteIndex]!; + + const core = uri ? ( + <Image + source={{ uri }} + style={[dimensionStyle, { backgroundColor: withAlpha(colors.textPrimary, 0.06) }]} + contentFit="cover" + transition={150} + /> + ) : ( + <View style={[styles.fallback, dimensionStyle, { backgroundColor: fallbackColor }]}> + <Text variant="caption" color="primary" style={{ fontSize: size * 0.4, fontWeight: '700' }}> + {initialsFor(name)} + </Text> + </View> + ); + + return ( + <View style={styles.wrapper}> + <View + style={ + ringColor + ? [ + styles.ring, + { + width: size + ringWidth * 2, + height: size + ringWidth * 2, + borderRadius: (size + ringWidth * 2) / 2, + borderWidth: ringWidth, + borderColor: ringColor, + }, + ] + : undefined + } + > + {core} + </View> + {badge ? ( + <View style={styles.badgeAnchor} pointerEvents="none"> + {badge} + </View> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + wrapper: { + alignSelf: 'flex-start', + }, + ring: { + alignItems: 'center', + justifyContent: 'center', + }, + fallback: { + alignItems: 'center', + justifyContent: 'center', + }, + badgeAnchor: { + position: 'absolute', + bottom: -2, + end: -2, + }, +}); diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..a8c705c --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,80 @@ +import type { PropsWithChildren } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +type Tone = 'brand' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'; + +interface BadgeProps extends PropsWithChildren { + color?: string; + textColor?: string; + /** Semantic tone — resolves background/text from the theme's soft tokens. Ignored when an + * explicit `color`/`textColor` is passed. */ + tone?: Tone; + /** Numeric count mode: renders `count` (capped at `max`) instead of `children`. */ + count?: number; + max?: number; +} + +const DEFAULT_MAX = 99; + +const TONE_TOKENS: Record< + Tone, + { + bgKey: 'brandSoft' | 'surface1' | 'successSoft' | 'warningSoft' | 'dangerSoft' | 'infoSoft'; + textKey: 'brand' | 'textSecondary' | 'success' | 'warning' | 'danger' | 'info'; + } +> = { + brand: { bgKey: 'brandSoft', textKey: 'brand' }, + neutral: { bgKey: 'surface1', textKey: 'textSecondary' }, + success: { bgKey: 'successSoft', textKey: 'success' }, + warning: { bgKey: 'warningSoft', textKey: 'warning' }, + danger: { bgKey: 'dangerSoft', textKey: 'danger' }, + info: { bgKey: 'infoSoft', textKey: 'info' }, +}; + +/** Generic pill badge. Two modes: pass `children` for a label badge (explicit `color`/`textColor` + * for semantic use, or `tone` to resolve from theme tokens), or pass `count`/`max` for a numeric + * count badge (e.g. unread counts) — count above `max` renders as `"{max}+"`. */ +export function Badge({ children, color, textColor, tone, count, max = DEFAULT_MAX }: BadgeProps) { + const { colors, spacing, radius } = useTheme(); + + if (typeof count === 'number' && count <= 0 && children === undefined) return null; + + const toneTokens = tone ? TONE_TOKENS[tone] : undefined; + const backgroundColor = color ?? (toneTokens ? colors[toneTokens.bgKey] : colors.surface2); + const resolvedTextColor = + textColor ?? (toneTokens ? colors[toneTokens.textKey] : colors.textPrimary); + + const content = typeof count === 'number' ? (count > max ? `${max}+` : String(count)) : children; + + return ( + <View + style={[ + styles.container, + { + backgroundColor, + borderRadius: radius.full, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2, + }, + ]} + > + <Text + variant="caption" + numeric={typeof count === 'number'} + style={{ color: resolvedTextColor, fontWeight: '600' }} + > + {content} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + alignSelf: 'flex-start', + }, +}); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..32c4a18 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,130 @@ +import type { ComponentProps } from 'react'; +import { useMemo } from 'react'; +import { ActivityIndicator, StyleSheet } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Icon, type IconName } from './icon'; +import { PressableScale } from './pressable-scale'; +import { Text } from './text'; + +type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'tonal'; +type Size = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends Omit<ComponentProps<typeof PressableScale>, 'style'> { + label: string; + variant?: Variant; + size?: Size; + loading?: boolean; + disabled?: boolean; + icon?: IconName; + fullWidth?: boolean; +} + +const SIZE_STYLES: Record< + Size, + { minHeight: number; paddingHorizontal: number; textVariant: 'callout' | 'body' | 'subtitle' } +> = { + sm: { minHeight: 36, paddingHorizontal: 14, textVariant: 'callout' }, + md: { minHeight: 48, paddingHorizontal: 20, textVariant: 'body' }, + lg: { minHeight: 56, paddingHorizontal: 24, textVariant: 'subtitle' }, +}; + +export function Button({ + label, + variant = 'primary', + size = 'md', + loading = false, + disabled = false, + icon, + fullWidth = false, + ...rest +}: ButtonProps) { + const { colors, spacing, radius } = useTheme(); + const isDisabled = disabled || loading; + + // Hoisted behind useMemo — Button renders on every list row/action bar, so rebuilding this + // object every render (to read a single variant's entry out of it) was needless per-render + // churn. + const variantStyles: Record< + Variant, + { background: string; text: 'onBrand' | 'primary' | 'danger' | 'brand'; borderColor?: string } + > = useMemo( + () => ({ + primary: { background: colors.brand, text: 'onBrand' }, + danger: { background: colors.danger, text: 'onBrand' }, + secondary: { + background: colors.surface1, + text: 'primary', + borderColor: colors.borderStrong, + }, + tonal: { background: colors.brandSoft, text: 'brand' }, + ghost: { background: 'transparent', text: 'primary' }, + }), + [colors], + ); + const { background, text, borderColor } = variantStyles[variant]; + const sizeStyle = SIZE_STYLES[size]; + + const iconColor = + text === 'onBrand' + ? colors.onBrand + : text === 'brand' + ? colors.brand + : text === 'danger' + ? colors.danger + : colors.textPrimary; + + return ( + <PressableScale + accessibilityRole="button" + accessibilityState={{ disabled: isDisabled }} + disabled={isDisabled} + haptic={isDisabled ? false : 'light'} + style={[ + styles.base, + { + backgroundColor: background, + borderRadius: radius.md, + minHeight: sizeStyle.minHeight, + paddingHorizontal: sizeStyle.paddingHorizontal, + gap: spacing.xs + 2, + opacity: isDisabled ? 0.5 : 1, + borderWidth: borderColor ? StyleSheet.hairlineWidth : 0, + borderColor, + alignSelf: fullWidth ? 'stretch' : 'flex-start', + width: fullWidth ? '100%' : undefined, + }, + ]} + {...rest} + > + {loading ? ( + <ActivityIndicator color={iconColor} /> + ) : ( + <> + {icon ? ( + <Icon + name={icon} + size={sizeStyle.textVariant === 'subtitle' ? 22 : 18} + color={iconColor} + /> + ) : null} + <Text variant={sizeStyle.textVariant} color={text} style={styles.label}> + {label} + </Text> + </> + )} + </PressableScale> + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + label: { + fontWeight: '600', + }, +}); diff --git a/src/components/ui/chip.test.tsx b/src/components/ui/chip.test.tsx new file mode 100644 index 0000000..5bab8a4 --- /dev/null +++ b/src/components/ui/chip.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; + +import { Chip } from './chip'; + +beforeAll(() => { + initI18n(); +}); + +describe('Chip', () => { + it('reflects selected state via accessibilityState', async () => { + await render(<Chip label="Unread" selected onPress={() => {}} />); + + const chip = screen.getByRole('button', { name: 'Unread' }); + expect(chip.props.accessibilityState).toMatchObject({ selected: true, disabled: false }); + }); + + it('reflects unselected state via accessibilityState', async () => { + await render(<Chip label="Unread" selected={false} onPress={() => {}} />); + + const chip = screen.getByRole('button', { name: 'Unread' }); + expect(chip.props.accessibilityState).toMatchObject({ selected: false }); + }); + + it('calls onPress when tapped', async () => { + const onPress = jest.fn(); + await render(<Chip label="Bot" onPress={onPress} />); + + fireEvent.press(screen.getByRole('button', { name: 'Bot' })); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('calls onRemove when the remove button is tapped, without triggering onPress', async () => { + const onPress = jest.fn(); + const onRemove = jest.fn(); + await render(<Chip label="VIP" onPress={onPress} onRemove={onRemove} />); + + fireEvent.press(screen.getByRole('button', { name: 'Remove VIP' })); + + expect(onRemove).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('renders without a remove button when onRemove is omitted', async () => { + await render(<Chip label="Archived" />); + + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); +}); diff --git a/src/components/ui/chip.tsx b/src/components/ui/chip.tsx new file mode 100644 index 0000000..d73621e --- /dev/null +++ b/src/components/ui/chip.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon, type IconName } from './icon'; +import { IconButton } from './icon-button'; +import { PressableScale } from './pressable-scale'; +import { Text } from './text'; + +type Tone = 'neutral' | 'brand' | 'custom'; +type Size = 'sm' | 'md'; + +interface ChipProps { + label: string; + selected?: boolean; + onPress?: () => void; + onRemove?: () => void; + leading?: IconName | ReactNode; + tone?: Tone; + /** Base color for `tone="custom"` (e.g. a channel or tag color). Ignored for other tones. */ + color?: string; + size?: Size; + disabled?: boolean; +} + +const SIZE_PADDING: Record<Size, number> = { sm: 6, md: 8 }; +const ICON_SIZE: Record<Size, number> = { sm: 12, md: 14 }; + +/** Filter/tag chip: selectable pill with an optional leading icon and optional remove (x) + * button. Selection state is exposed to assistive tech via `accessibilityState`, and selecting + * fires a light 'selection' haptic. */ +export function Chip({ + label, + selected = false, + onPress, + onRemove, + leading, + tone = 'neutral', + color, + size = 'md', + disabled = false, +}: ChipProps) { + const { t } = useTranslation(); + const { colors, radius, spacing } = useTheme(); + + const baseColor = + tone === 'brand' + ? colors.brand + : tone === 'custom' + ? (color ?? colors.brand) + : colors.textSecondary; + + const backgroundColor = selected ? withAlpha(baseColor, 0.16) : colors.surface1; + const borderColor = selected ? withAlpha(baseColor, 0.4) : colors.borderSubtle; + const textColorStyle = selected ? baseColor : colors.textSecondary; + + const content = ( + <View + style={[ + styles.container, + { + backgroundColor, + borderColor, + borderRadius: radius.full, + paddingVertical: SIZE_PADDING[size], + paddingStart: spacing.ms, + paddingEnd: onRemove ? spacing.xs : spacing.ms, + gap: spacing.xs, + opacity: disabled ? 0.5 : 1, + }, + ]} + > + {typeof leading === 'string' ? ( + <Icon name={leading as IconName} size={ICON_SIZE[size]} color={textColorStyle} /> + ) : ( + leading + )} + <Text variant="caption" numeric style={{ color: textColorStyle, fontWeight: '600' }}> + {label} + </Text> + {onRemove ? ( + <IconButton + accessibilityLabel={t('common.removeItem', { defaultValue: 'Remove {{label}}', label })} + icon="close" + size="sm" + variant="ghost" + tint={textColorStyle} + haptic={false} + disabled={disabled} + onPress={onRemove} + style={styles.removeButton} + /> + ) : null} + </View> + ); + + if (!onPress) return content; + + return ( + <PressableScale + accessibilityRole="button" + accessibilityLabel={label} + accessibilityState={{ selected, disabled }} + disabled={disabled} + onPress={onPress} + haptic={disabled ? false : 'selection'} + > + {content} + </PressableScale> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: StyleSheet.hairlineWidth, + alignSelf: 'flex-start', + }, + removeButton: { + width: 20, + height: 20, + }, +}); diff --git a/src/components/ui/connection-banner.tsx b/src/components/ui/connection-banner.tsx new file mode 100644 index 0000000..2d9402c --- /dev/null +++ b/src/components/ui/connection-banner.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon } from './icon'; +import { Text } from './text'; + +export type ConnectionState = 'online' | 'connecting' | 'reconnecting' | 'offline'; + +interface ConnectionBannerProps { + state: ConnectionState; +} + +const AUTO_HIDE_AFTER_ONLINE_MS = 1200; +const BANNER_HEIGHT = 36; + +/** Slide-from-top presentational banner for realtime connection state. Stays visible for any + * non-'online' state; once state transitions to 'online' it holds briefly (so the "back online" + * confirmation is readable) then auto-hides. Purely presentational — callers own translating + * actual socket state into `state`. */ +export function ConnectionBanner({ state }: ConnectionBannerProps) { + const { t } = useTranslation(); + const { colors, motion } = useTheme(); + const reducedMotion = useReducedMotion(); + const translateY = useSharedValue(-BANNER_HEIGHT); + // Whether we're still holding the banner visible after a transition to 'online', during the + // brief confirmation window before auto-hiding. Derived synchronously from `state` wherever + // possible; the one place this must be set imperatively is the hold timer below, which is a + // deliberate external-timer synchronization (exactly what effects are for), not a disguised + // derived-state computation — see the inline suppression there. + const [isHoldingAfterOnline, setIsHoldingAfterOnline] = useState(state === 'online'); + const shouldShow = state !== 'online' || isHoldingAfterOnline; + + useEffect(() => { + if (state !== 'online') { + translateY.value = reducedMotion ? 0 : withTiming(0, { duration: motion.durations.base }); + return undefined; + } + + // Just transitioned to 'online' — hold visible briefly (so the confirmation is readable), + // then slide away. This synchronizes to a real external timer (setTimeout), which is exactly + // what effects are for; the lint rule's "avoid setState in an effect body" heuristic doesn't + // have a way to distinguish that from disguised derived state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsHoldingAfterOnline(true); + const timer = setTimeout(() => { + translateY.value = reducedMotion + ? -BANNER_HEIGHT + : withTiming(-BANNER_HEIGHT, { duration: motion.durations.base }); + setIsHoldingAfterOnline(false); + }, AUTO_HIDE_AFTER_ONLINE_MS); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `translateY` is a stable shared-value ref. + }, [state, motion.durations.base, reducedMotion]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + // Hoisted behind useMemo — this banner mounts on both the Inbox and chat screens and rebuilding + // all four states' config every render (to read one) was needless per-render churn. + const config: Record< + ConnectionState, + { + icon: 'cloud-offline-outline' | 'sync-outline' | 'checkmark-circle-outline'; + background: string; + textColor: string; + label: string; + } + > = useMemo( + () => ({ + online: { + icon: 'checkmark-circle-outline', + background: colors.successSoft, + textColor: colors.success, + label: t('realtime.online', { defaultValue: 'Back online' }), + }, + connecting: { + icon: 'sync-outline', + background: colors.infoSoft, + textColor: colors.info, + label: t('realtime.connecting', { defaultValue: 'Connecting…' }), + }, + reconnecting: { + icon: 'sync-outline', + background: colors.warningSoft, + textColor: colors.warning, + label: t('realtime.reconnecting', { defaultValue: 'Reconnecting…' }), + }, + offline: { + icon: 'cloud-offline-outline', + background: colors.dangerSoft, + textColor: colors.danger, + label: t('realtime.offline', { defaultValue: "You're offline" }), + }, + }), + [colors, t], + ); + const { icon, background, textColor, label } = config[state]; + + if (!shouldShow) return null; + + return ( + <Animated.View + accessibilityLiveRegion="polite" + accessibilityRole="alert" + style={[ + styles.container, + { height: BANNER_HEIGHT, backgroundColor: background }, + reducedMotion ? undefined : animatedStyle, + ]} + > + <Icon name={icon} size={16} color={textColor} /> + <Text variant="caption" style={{ color: textColor, fontWeight: '600' }}> + {label} + </Text> + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + width: '100%', + }, +}); diff --git a/src/components/ui/count-pill.test.tsx b/src/components/ui/count-pill.test.tsx new file mode 100644 index 0000000..edfa34d --- /dev/null +++ b/src/components/ui/count-pill.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; + +import { CountPill } from './count-pill'; + +beforeAll(() => { + initI18n(); +}); + +describe('CountPill', () => { + it('renders the raw count when under the cap', async () => { + await render(<CountPill count={7} />); + + expect(screen.getByText('7')).toBeTruthy(); + }); + + it('renders "{max}+" when the count exceeds the default cap of 99', async () => { + await render(<CountPill count={140} />); + + expect(screen.getByText('99+')).toBeTruthy(); + }); + + it('respects a custom max', async () => { + await render(<CountPill count={12} max={9} />); + + expect(screen.getByText('9+')).toBeTruthy(); + }); + + it('renders nothing when count is 0', async () => { + const { toJSON } = await render(<CountPill count={0} />); + + expect(toJSON()).toBeNull(); + }); +}); diff --git a/src/components/ui/count-pill.tsx b/src/components/ui/count-pill.tsx new file mode 100644 index 0000000..70bc7bb --- /dev/null +++ b/src/components/ui/count-pill.tsx @@ -0,0 +1,63 @@ +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +type Tone = 'brand' | 'neutral' | 'danger'; + +interface CountPillProps { + count: number; + /** Display cap — counts above this render as `"{max}+"`. Defaults to 99. */ + max?: number; + tone?: Tone; +} + +const DEFAULT_MAX = 99; +const PILL_SIZE = 18; + +/** Small numeric pill for unread counts, badge counts, etc. Renders nothing when `count` is 0 or + * less — callers don't need to guard the render themselves. */ +export function CountPill({ count, max = DEFAULT_MAX, tone = 'brand' }: CountPillProps) { + const { colors } = useTheme(); + + if (count <= 0) return null; + + const label = count > max ? `${max}+` : String(count); + + const toneStyle: Record<Tone, { background: string; text: 'onBrand' | 'inverse' }> = { + brand: { background: colors.brand, text: 'onBrand' }, + neutral: { background: colors.textTertiary, text: 'inverse' }, + danger: { background: colors.danger, text: 'onBrand' }, + }; + const { background, text } = toneStyle[tone]; + + return ( + <View + style={[ + styles.container, + { + backgroundColor: background, + minWidth: PILL_SIZE, + height: PILL_SIZE, + borderRadius: PILL_SIZE / 2, + paddingHorizontal: label.length > 1 ? 5 : 0, + }, + ]} + > + <Text variant="micro" color={text} numeric style={styles.label}> + {label} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + label: { + fontWeight: '700', + }, +}); diff --git a/src/components/ui/date-separator.tsx b/src/components/ui/date-separator.tsx new file mode 100644 index 0000000..378697e --- /dev/null +++ b/src/components/ui/date-separator.tsx @@ -0,0 +1,62 @@ +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +interface DateSeparatorProps { + /** ISO date string (or anything dayjs can parse). */ + date: string; +} + +/** Centered pill label for message-list date breaks: "Today" / "Yesterday" / a localized date for + * anything older. */ +export function DateSeparator({ date }: DateSeparatorProps) { + const { t } = useTranslation(); + const { colors, radius, spacing } = useTheme(); + + const target = dayjs(date); + const today = dayjs(); + const isToday = target.isSame(today, 'day'); + const isYesterday = target.isSame(today.subtract(1, 'day'), 'day'); + const isSameYear = target.isSame(today, 'year'); + + const label = isToday + ? t('common.today', { defaultValue: 'Today' }) + : isYesterday + ? t('common.yesterday', { defaultValue: 'Yesterday' }) + : target.format(isSameYear ? 'MMMM D' : 'MMMM D, YYYY'); + + return ( + <View style={styles.row}> + <View + style={[ + styles.pill, + { + backgroundColor: colors.surface1, + borderRadius: radius.full, + paddingHorizontal: spacing.ms, + paddingVertical: spacing.xs, + }, + ]} + > + <Text variant="micro" color="secondary" style={styles.label}> + {label} + </Text> + </View> + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + alignItems: 'center', + width: '100%', + }, + pill: {}, + label: { + fontWeight: '600', + }, +}); diff --git a/src/components/ui/divider.tsx b/src/components/ui/divider.tsx new file mode 100644 index 0000000..8942e16 --- /dev/null +++ b/src/components/ui/divider.tsx @@ -0,0 +1,31 @@ +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +interface DividerProps { + /** Insets the divider from the leading edge (e.g. to align past a leading icon/avatar in a + * list). */ + inset?: boolean; +} + +/** Hairline horizontal rule using the subtle border token. */ +export function Divider({ inset = false }: DividerProps) { + const { colors, spacing } = useTheme(); + + return ( + <View + style={[ + styles.line, + { backgroundColor: colors.borderSubtle }, + inset ? { marginStart: spacing.xl + spacing.sm } : undefined, + ]} + /> + ); +} + +const styles = StyleSheet.create({ + line: { + height: StyleSheet.hairlineWidth, + width: '100%', + }, +}); diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..d7f324d --- /dev/null +++ b/src/components/ui/empty-state.tsx @@ -0,0 +1,77 @@ +import { StyleSheet, View } from 'react-native'; +import Animated, { FadeInUp } from 'react-native-reanimated'; + +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { Button } from './button'; +import { Icon, type IconName } from './icon'; +import { Text } from './text'; + +interface EmptyStateProps { + icon?: IconName; + title: string; + description?: string; + action?: { label: string; onPress: () => void }; +} + +const ICON_CIRCLE_SIZE = 72; + +export function EmptyState({ + icon = 'file-tray-outline', + title, + description, + action, +}: EmptyStateProps) { + const { colors, spacing } = useTheme(); + const reducedMotion = useReducedMotion(); + + return ( + <Animated.View + entering={reducedMotion ? undefined : FadeInUp.duration(280)} + style={[styles.container, { padding: spacing.xl, gap: spacing.sm }]} + > + <View + style={[ + styles.iconCircle, + { + width: ICON_CIRCLE_SIZE, + height: ICON_CIRCLE_SIZE, + borderRadius: ICON_CIRCLE_SIZE / 2, + backgroundColor: colors.brandSoft, + }, + ]} + > + <Icon name={icon} size={32} color={colors.brand} /> + </View> + <Text variant="title" style={styles.centered}> + {title} + </Text> + {description ? ( + <Text variant="body" color="secondary" style={styles.centered}> + {description} + </Text> + ) : null} + {action ? ( + <View style={{ marginTop: spacing.xs }}> + <Button label={action.label} variant="tonal" onPress={action.onPress} /> + </View> + ) : null} + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + iconCircle: { + alignItems: 'center', + justifyContent: 'center', + }, + centered: { + textAlign: 'center', + }, +}); diff --git a/src/components/ui/error-banner.tsx b/src/components/ui/error-banner.tsx new file mode 100644 index 0000000..2c6521b --- /dev/null +++ b/src/components/ui/error-banner.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Icon } from './icon'; +import { Text } from './text'; + +interface ErrorBannerProps { + message: string; + actionLabel?: string; + onAction?: () => void; + tone?: 'danger' | 'warning' | 'info'; +} + +/** Inline banner for errors and workspace-blocked/quota states. Not a toast — stays mounted until + * the caller removes it (composer quota banner, list-level fetch error, etc). */ +export function ErrorBanner({ message, actionLabel, onAction, tone = 'danger' }: ErrorBannerProps) { + const { colors, spacing, radius } = useTheme(); + + // Hoisted behind useMemo — rebuilding all three tones every render (to read one) was needless + // per-render churn on a component that can sit mounted for a whole screen's lifetime. + const toneMap = useMemo( + () => ({ + danger: { tint: colors.danger, soft: colors.dangerSoft }, + warning: { tint: colors.warning, soft: colors.warningSoft }, + info: { tint: colors.info, soft: colors.infoSoft }, + }), + [colors], + ); + const toneTokens = toneMap[tone]; + + return ( + <View + accessibilityLiveRegion="polite" + accessibilityRole="alert" + style={[ + styles.container, + { + backgroundColor: toneTokens.soft, + borderRadius: radius.md, + padding: spacing.ms, + gap: spacing.xs, + }, + ]} + > + <Icon + name={tone === 'info' ? 'information-circle' : 'alert-circle'} + size={18} + color={toneTokens.tint} + /> + <Text variant="caption" style={styles.message}> + {message} + </Text> + {actionLabel && onAction ? ( + <Pressable + onPress={onAction} + accessibilityRole="button" + accessibilityLabel={actionLabel} + hitSlop={8} + > + <Text variant="caption" style={{ color: toneTokens.tint, fontWeight: '700' }}> + {actionLabel} + </Text> + </Pressable> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + message: { + flex: 1, + }, +}); diff --git a/src/components/ui/icon-button.test.tsx b/src/components/ui/icon-button.test.tsx new file mode 100644 index 0000000..77ee7a4 --- /dev/null +++ b/src/components/ui/icon-button.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; + +import { IconButton } from './icon-button'; + +beforeAll(() => { + initI18n(); +}); + +describe('IconButton', () => { + it('exposes the required accessibilityLabel and button role', async () => { + await render( + <IconButton accessibilityLabel="Close conversation" icon="close" onPress={() => {}} />, + ); + + const button = screen.getByRole('button', { name: 'Close conversation' }); + expect(button).toBeTruthy(); + }); + + it('calls onPress when pressed', async () => { + const onPress = jest.fn(); + await render( + <IconButton accessibilityLabel="Archive" icon="archive-outline" onPress={onPress} />, + ); + + fireEvent.press(screen.getByRole('button', { name: 'Archive' })); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('marks itself disabled via accessibilityState when loading', async () => { + await render(<IconButton accessibilityLabel="Send" icon="send" loading onPress={() => {}} />); + + const button = screen.getByRole('button', { name: 'Send' }); + expect(button.props.accessibilityState).toMatchObject({ disabled: true, busy: true }); + }); + + it('does not call onPress when disabled', async () => { + const onPress = jest.fn(); + await render( + <IconButton accessibilityLabel="Delete" icon="trash-outline" disabled onPress={onPress} />, + ); + + fireEvent.press(screen.getByRole('button', { name: 'Delete' })); + + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx new file mode 100644 index 0000000..44dfb12 --- /dev/null +++ b/src/components/ui/icon-button.tsx @@ -0,0 +1,137 @@ +import type { ComponentProps } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon, type IconName } from './icon'; +import { CountPill } from './count-pill'; +import { PressableScale } from './pressable-scale'; + +type Size = 'sm' | 'md' | 'lg'; +type Variant = 'ghost' | 'tonal' | 'filled' | 'outline'; + +const SIZE_MAP: Record<Size, number> = { sm: 32, md: 40, lg: 48 }; +const ICON_SIZE_MAP: Record<Size, number> = { sm: 16, md: 20, lg: 24 }; +const MIN_HIT_TARGET = 44; + +interface IconButtonProps extends Omit< + ComponentProps<typeof PressableScale>, + 'style' | 'children' +> { + /** Required — every icon-only control must be independently labeled for screen readers. There + * is no default: an icon glyph alone conveys no meaning to VoiceOver/TalkBack. */ + accessibilityLabel: string; + icon: IconName; + size?: Size; + variant?: Variant; + /** Overrides the icon/border tint. Defaults to a variant-appropriate color from the theme. */ + tint?: string; + badgeCount?: number; + loading?: boolean; + disabled?: boolean; + /** Overrides the pressable box's size/layout — used by composite primitives (e.g. Chip's + * inline remove button) that need a smaller box than the `size` presets provide. */ + style?: StyleProp<ViewStyle>; +} + +/** Icon-only pressable control. Visual size can be as small as 32pt (`sm`), but `hitSlop` always + * pads the touch target up to at least 44x44 — the platform-recommended minimum — regardless of + * the visual box, so small icon buttons stay comfortably tappable. */ +export function IconButton({ + accessibilityLabel, + icon, + size = 'md', + variant = 'ghost', + tint, + badgeCount, + loading = false, + disabled = false, + haptic, + style, + ...rest +}: IconButtonProps) { + const { colors, radius } = useTheme(); + const boxSize = SIZE_MAP[size]; + const iconSize = ICON_SIZE_MAP[size]; + const isDisabled = disabled || loading; + const hitSlopValue = Math.max(0, Math.ceil((MIN_HIT_TARGET - boxSize) / 2)); + + const resolvedTint = tint ?? colors.textPrimary; + + const variantStyle = (() => { + switch (variant) { + case 'filled': + return { + backgroundColor: tint ?? colors.brand, + iconColor: tint ? resolvedTint : colors.onBrand, + }; + case 'tonal': + return { backgroundColor: withAlpha(resolvedTint, 0.14), iconColor: resolvedTint }; + case 'outline': + return { + backgroundColor: 'transparent', + iconColor: resolvedTint, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderStrong, + }; + case 'ghost': + default: + return { backgroundColor: 'transparent', iconColor: resolvedTint }; + } + })(); + + return ( + <View style={styles.wrapper}> + <PressableScale + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityState={{ disabled: isDisabled, busy: loading }} + disabled={isDisabled} + hitSlop={hitSlopValue} + haptic={isDisabled ? false : haptic} + style={[ + styles.base, + { + width: boxSize, + height: boxSize, + borderRadius: radius.full, + backgroundColor: variantStyle.backgroundColor, + borderWidth: 'borderWidth' in variantStyle ? variantStyle.borderWidth : 0, + borderColor: 'borderColor' in variantStyle ? variantStyle.borderColor : undefined, + opacity: isDisabled ? 0.4 : 1, + }, + style, + ]} + {...rest} + > + {loading ? ( + <ActivityIndicator size="small" color={variantStyle.iconColor} /> + ) : ( + <Icon name={icon} size={iconSize} color={variantStyle.iconColor} flipRTL /> + )} + </PressableScale> + {typeof badgeCount === 'number' && badgeCount > 0 ? ( + <View style={styles.badgeAnchor} pointerEvents="none"> + <CountPill count={badgeCount} tone="danger" /> + </View> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + wrapper: { + alignSelf: 'flex-start', + }, + base: { + alignItems: 'center', + justifyContent: 'center', + }, + badgeAnchor: { + position: 'absolute', + top: -4, + end: -4, + }, +}); diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx new file mode 100644 index 0000000..3de47a3 --- /dev/null +++ b/src/components/ui/icon.tsx @@ -0,0 +1,21 @@ +import { Ionicons } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; +import { I18nManager } from 'react-native'; + +export type IconName = ComponentProps<typeof Ionicons>['name']; + +interface IconProps { + name: IconName; + size?: number; + color: string; + /** Mirror this icon horizontally in RTL layouts (chevrons, send arrow, etc). `I18nManager.isRTL` + * only changes on app reload, so reading it at render time is safe — no live-toggle case exists. */ + flipRTL?: boolean; +} + +/** Single wrapper around the icon set in use (Ionicons) so swapping icon libraries later touches + * one file instead of every call site. */ +export function Icon({ name, size = 24, color, flipRTL = false }: IconProps) { + const style = flipRTL && I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined; + return <Ionicons name={name} size={size} color={color} style={style} />; +} diff --git a/src/components/ui/list-footer-spinner.tsx b/src/components/ui/list-footer-spinner.tsx new file mode 100644 index 0000000..c629dce --- /dev/null +++ b/src/components/ui/list-footer-spinner.tsx @@ -0,0 +1,24 @@ +import { ActivityIndicator, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +interface ListFooterSpinnerProps { + visible: boolean; + /** Reserved height for the footer slot — kept constant whether or not the spinner is showing, + * so toggling `visible` never shifts list content (no jump on pagination end). */ + height?: number; +} + +const DEFAULT_HEIGHT = 56; + +/** FlatList/FlashList `ListFooterComponent` for infinite-scroll pagination. Always reserves + * `height`, whether or not the spinner is actually visible. */ +export function ListFooterSpinner({ visible, height = DEFAULT_HEIGHT }: ListFooterSpinnerProps) { + const { colors } = useTheme(); + + return ( + <View style={{ height, alignItems: 'center', justifyContent: 'center' }}> + {visible ? <ActivityIndicator color={colors.textSecondary} /> : null} + </View> + ); +} diff --git a/src/components/ui/list-item.tsx b/src/components/ui/list-item.tsx new file mode 100644 index 0000000..3fa9258 --- /dev/null +++ b/src/components/ui/list-item.tsx @@ -0,0 +1,148 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import type { AccessibilityState } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import { alphaTokens, withAlpha } from '@/theme/color-utils'; +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon } from './icon'; +import { PressableScale } from './pressable-scale'; +import { Text } from './text'; + +interface ListItemProps extends PropsWithChildren { + title: string; + subtitle?: string; + leading?: ReactNode; + trailing?: ReactNode; + onPress?: () => void; + showChevron?: boolean; + /** Renders title in the danger color and skips the scale-press haptic escalation — for + * destructive actions (delete, remove, leave). */ + destructive?: boolean; + /** Right-aligned secondary text shown before `trailing` (e.g. a current value/setting). */ + value?: string; + disabled?: boolean; + /** Extra accessibility state merged with `{ disabled }` (e.g. `{ selected: true }` for a row in + * a single-select list like the workspace picker). */ + accessibilityState?: AccessibilityState; +} + +const MIN_HEIGHT = 52; + +/** Generic row: leading slot (avatar/icon), title/subtitle, trailing slot. Used for settings rows, + * contact rows, member rows — anywhere the row is press-to-navigate. */ +export function ListItem({ + title, + subtitle, + leading, + trailing, + onPress, + showChevron = false, + destructive = false, + value, + disabled = false, + accessibilityState, + children, +}: ListItemProps) { + const { colors, spacing } = useTheme(); + const reducedMotion = useReducedMotion(); + const overlayOpacity = useSharedValue(0); + + const overlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value })); + + const content = ( + <View style={styles.stack}> + <View + style={[ + styles.row, + { + minHeight: MIN_HEIGHT, + paddingVertical: spacing.ms, + paddingHorizontal: spacing.md, + gap: spacing.ms, + opacity: disabled ? 0.5 : 1, + }, + ]} + > + {leading} + <View style={styles.body}> + <Text variant="body" numberOfLines={1} color={destructive ? 'danger' : 'primary'}> + {title} + </Text> + {subtitle ? ( + <Text variant="caption" color="secondary" numberOfLines={1}> + {subtitle} + </Text> + ) : null} + {children} + </View> + {value ? ( + <Text variant="callout" color="secondary" numberOfLines={1}> + {value} + </Text> + ) : null} + {trailing} + {showChevron ? ( + <Icon name="chevron-forward" size={18} color={colors.textSecondary} flipRTL /> + ) : null} + </View> + <Animated.View + pointerEvents="none" + style={[ + styles.overlay, + { backgroundColor: withAlpha(colors.textPrimary, alphaTokens.pressed) }, + overlayStyle, + ]} + /> + </View> + ); + + if (!onPress) { + return content; + } + + return ( + <PressableScale + accessibilityRole="button" + accessibilityState={{ ...accessibilityState, disabled }} + disabled={disabled} + // Plain navigation rows (the overwhelming majority of ListItem uses — settings, contacts, + // members) get no haptic: a tap that just pushes a screen doesn't need feedback beyond the + // screen transition itself. Destructive rows keep the 'medium' haptic as extra confirmation + // weight for an action that can't be casually undone. + haptic={disabled ? false : destructive ? 'medium' : false} + onPress={onPress} + onPressIn={() => { + // reanimated `SharedValue.value` is a mutable UI-thread ref, not React state — see + // pressable-scale.tsx for the fuller explanation of this lint false-positive. + // eslint-disable-next-line react-hooks/immutability + overlayOpacity.value = reducedMotion ? 1 : withTiming(1, { duration: 100 }); + }} + onPressOut={() => { + // eslint-disable-next-line react-hooks/immutability -- see onPressIn above. + overlayOpacity.value = reducedMotion ? 0 : withTiming(0, { duration: 150 }); + }} + > + {content} + </PressableScale> + ); +} + +const styles = StyleSheet.create({ + stack: { + position: 'relative', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + }, + body: { + flex: 1, + gap: 2, + }, + overlay: { + ...StyleSheet.absoluteFill, + }, +}); diff --git a/src/components/ui/pressable-scale.tsx b/src/components/ui/pressable-scale.tsx new file mode 100644 index 0000000..deeec24 --- /dev/null +++ b/src/components/ui/pressable-scale.tsx @@ -0,0 +1,63 @@ +import type { ComponentProps } from 'react'; +import { Pressable } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { type HapticKind, triggerHaptic } from '@/lib/haptics'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +interface PressableScaleProps extends ComponentProps<typeof Pressable> { + /** Scale factor applied on press-in. Defaults to the `pressScale` motion token. */ + scaleTo?: number; + /** Haptic kind fired on press-in. Pass `false` to suppress. Defaults to `'light'`. */ + haptic?: HapticKind | false; +} + +/** Pressable wrapper that scales down on press via reanimated, using the shared `pressScale` + * token. Respects `useReducedMotion()` — when reduced motion is on, the scale animation is + * skipped entirely (no transform change) but the haptic still fires, since haptics aren't + * motion. */ +export function PressableScale({ + scaleTo, + haptic = 'light', + onPressIn, + onPressOut, + style, + ...rest +}: PressableScaleProps) { + const { motion } = useTheme(); + const reducedMotion = useReducedMotion(); + const scale = useSharedValue(1); + const targetScale = scaleTo ?? motion.pressScale; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + <AnimatedPressable + style={[reducedMotion ? undefined : animatedStyle, style]} + onPressIn={(event) => { + if (!reducedMotion) { + // reanimated `SharedValue.value` is a mutable UI-thread ref by design (not React + // state); the react-compiler lint rule can't tell it apart from a plain object. + // eslint-disable-next-line react-hooks/immutability + scale.value = withTiming(targetScale, { duration: motion.durations.instant }); + } + if (haptic) triggerHaptic(haptic); + onPressIn?.(event); + }} + onPressOut={(event) => { + if (!reducedMotion) { + // eslint-disable-next-line react-hooks/immutability -- see onPressIn above. + scale.value = withTiming(1, { duration: motion.durations.fast }); + } + onPressOut?.(event); + }} + {...rest} + /> + ); +} diff --git a/src/components/ui/relative-time.tsx b/src/components/ui/relative-time.tsx new file mode 100644 index 0000000..82578fe --- /dev/null +++ b/src/components/ui/relative-time.tsx @@ -0,0 +1,19 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +import { Text } from './text'; + +dayjs.extend(relativeTime); + +interface RelativeTimeProps { + date: string | Date; + variant?: 'caption' | 'body'; +} + +export function RelativeTime({ date, variant = 'caption' }: RelativeTimeProps) { + return ( + <Text variant={variant} color="secondary"> + {dayjs(date).fromNow()} + </Text> + ); +} diff --git a/src/components/ui/screen-header.tsx b/src/components/ui/screen-header.tsx new file mode 100644 index 0000000..05c6061 --- /dev/null +++ b/src/components/ui/screen-header.tsx @@ -0,0 +1,70 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +interface ScreenHeaderProps extends PropsWithChildren { + title?: string; + subtitle?: string; + leading?: ReactNode; + trailing?: ReactNode; + /** Renders `title` with the `display` variant instead of `title` — for top-level tab screens + * that want a large, prominent heading. */ + large?: boolean; +} + +/** Screen-level header row: optional leading slot (back button, avatar), title/subtitle stack, + * trailing slot (actions), and a `children` slot below for search bars / filter rows. */ +export function ScreenHeader({ + title, + subtitle, + leading, + trailing, + large = false, + children, +}: ScreenHeaderProps) { + const { spacing } = useTheme(); + + return ( + <View + style={[ + styles.container, + { paddingHorizontal: spacing.md, paddingTop: spacing.sm, gap: spacing.sm }, + ]} + > + <View style={[styles.row, { gap: spacing.sm }]}> + {leading} + <View style={styles.titleBlock}> + {title ? ( + <Text variant={large ? 'display' : 'heading'} numberOfLines={1}> + {title} + </Text> + ) : null} + {subtitle ? ( + <Text variant="caption" color="secondary" numberOfLines={1}> + {subtitle} + </Text> + ) : null} + </View> + {trailing ? <View style={[styles.row, { gap: spacing.xs }]}>{trailing}</View> : null} + </View> + {children} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + }, + titleBlock: { + flex: 1, + gap: 2, + }, +}); diff --git a/src/components/ui/screen.tsx b/src/components/ui/screen.tsx new file mode 100644 index 0000000..ad1f6cf --- /dev/null +++ b/src/components/ui/screen.tsx @@ -0,0 +1,37 @@ +import type { PropsWithChildren } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { type Edge, SafeAreaView } from 'react-native-safe-area-context'; + +import { useTheme } from '@/theme/use-theme'; + +interface ScreenProps extends PropsWithChildren { + style?: StyleProp<ViewStyle>; + edges?: Edge[]; + padded?: boolean; +} + +/** Base screen wrapper: theme background + safe-area insets. Use for every top-level route. */ +export function Screen({ children, style, edges, padded = false }: ScreenProps) { + const { colors, spacing } = useTheme(); + + return ( + <SafeAreaView + edges={edges} + style={[ + styles.container, + { backgroundColor: colors.bg }, + padded && { padding: spacing.md }, + style, + ]} + > + {children} + </SafeAreaView> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/src/components/ui/search-bar.tsx b/src/components/ui/search-bar.tsx new file mode 100644 index 0000000..b8cae22 --- /dev/null +++ b/src/components/ui/search-bar.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, TextInput, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Icon } from './icon'; +import { IconButton } from './icon-button'; + +interface SearchBarProps { + value: string; + onChangeText: (text: string) => void; + placeholder?: string; +} + +export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps) { + const { t } = useTranslation(); + const { colors, spacing, radius, scheme } = useTheme(); + + return ( + <View + accessibilityRole="search" + style={[ + styles.container, + { + backgroundColor: colors.surface2, + borderRadius: radius.md, + paddingHorizontal: spacing.ms, + gap: spacing.xs, + }, + ]} + > + <Icon name="search" size={18} color={colors.textSecondary} /> + <TextInput + value={value} + onChangeText={onChangeText} + placeholder={placeholder} + placeholderTextColor={colors.textSecondary} + style={[styles.input, { color: colors.textPrimary }]} + returnKeyType="search" + autoCorrect={false} + keyboardAppearance={scheme} + /> + {value.length > 0 ? ( + <IconButton + accessibilityLabel={t('common.clear')} + icon="close-circle" + size="sm" + variant="ghost" + tint={colors.textSecondary} + haptic={false} + onPress={() => onChangeText('')} + /> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + height: 40, + }, + input: { + flex: 1, + fontSize: 16, + height: '100%', + }, +}); diff --git a/src/components/ui/section-header.tsx b/src/components/ui/section-header.tsx new file mode 100644 index 0000000..c4c1ee8 --- /dev/null +++ b/src/components/ui/section-header.tsx @@ -0,0 +1,44 @@ +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +interface SectionHeaderProps { + title: string; + action?: { label: string; onPress: () => void }; +} + +export function SectionHeader({ title, action }: SectionHeaderProps) { + const { spacing } = useTheme(); + + return ( + <View + style={[ + styles.container, + { paddingHorizontal: spacing.md, paddingTop: spacing.md, paddingBottom: spacing.xs }, + ]} + > + <Text variant="caption" color="secondary" style={styles.title}> + {title.toUpperCase()} + </Text> + {action ? ( + <Text variant="caption" color="secondary" onPress={action.onPress}> + {action.label} + </Text> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + title: { + fontWeight: '700', + letterSpacing: 0.5, + }, +}); diff --git a/src/components/ui/segmented-tabs.tsx b/src/components/ui/segmented-tabs.tsx new file mode 100644 index 0000000..af6b643 --- /dev/null +++ b/src/components/ui/segmented-tabs.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { I18nManager, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import { triggerHaptic } from '@/lib/haptics'; +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +interface SegmentedTabsProps<T extends string> { + options: { value: T; label: string }[]; + value: T; + onChange: (value: T) => void; +} + +export function SegmentedTabs<T extends string>({ + options, + value, + onChange, +}: SegmentedTabsProps<T>) { + const { colors, spacing, radius, motion } = useTheme(); + const reducedMotion = useReducedMotion(); + + const [containerWidth, setContainerWidth] = useState(0); + const indicatorX = useSharedValue(0); + const indicatorWidth = useSharedValue(0); + + const activeIndex = Math.max( + 0, + options.findIndex((option) => option.value === value), + ); + const segmentWidth = containerWidth > 0 ? containerWidth / options.length : 0; + + useEffect(() => { + if (segmentWidth <= 0) return; + // Mirror the indicator's horizontal position in RTL so it tracks the visually-active segment + // (flexDirection isn't flipped here, so the logical index must be translated manually). + const visualIndex = I18nManager.isRTL ? options.length - 1 - activeIndex : activeIndex; + const targetX = visualIndex * segmentWidth + 2; + const targetWidth = segmentWidth - 4; + + if (reducedMotion) { + indicatorX.value = targetX; + indicatorWidth.value = targetWidth; + } else { + indicatorX.value = withTiming(targetX, { duration: motion.durations.base }); + indicatorWidth.value = withTiming(targetWidth, { duration: motion.durations.base }); + } + }, [ + activeIndex, + segmentWidth, + options.length, + reducedMotion, + motion.durations.base, + indicatorX, + indicatorWidth, + ]); + + const indicatorStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: indicatorX.value }], + width: indicatorWidth.value, + })); + + function handleLayout(event: LayoutChangeEvent) { + setContainerWidth(event.nativeEvent.layout.width); + } + + return ( + <View + onLayout={handleLayout} + style={[ + styles.container, + { backgroundColor: colors.surface2, borderRadius: radius.md, padding: 2 }, + ]} + > + {containerWidth > 0 ? ( + <Animated.View + style={[ + styles.indicator, + indicatorStyle, + { + backgroundColor: colors.surface1, + borderRadius: radius.sm + 2, + top: 2, + bottom: 2, + }, + ]} + /> + ) : null} + {options.map((option) => { + const isActive = option.value === value; + return ( + <Pressable + key={option.value} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + onPress={() => { + if (option.value !== value) { + triggerHaptic('selection'); + onChange(option.value); + } + }} + style={[styles.segment, { paddingVertical: spacing.xs + 2, minHeight: 44 }]} + > + <Text variant="caption" color={isActive ? 'primary' : 'secondary'} style={styles.label}> + {option.label} + </Text> + </Pressable> + ); + })} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + position: 'relative', + }, + indicator: { + position: 'absolute', + start: 0, + }, + segment: { + flex: 1, + alignItems: 'center', + zIndex: 1, + }, + label: { + fontWeight: '600', + }, +}); diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..94e6eb0 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,136 @@ +import BottomSheet, { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from '@gorhom/bottom-sheet'; +import { forwardRef, type PropsWithChildren, type ReactNode, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { IconButton } from './icon-button'; +import { Text } from './text'; + +interface SheetProps extends PropsWithChildren { + snapPoints?: (string | number)[]; + onDismiss?: () => void; +} + +/** Wraps @gorhom/bottom-sheet with our theme colors and a tap-to-dismiss backdrop. Used for + * filter/saved-replies/assignment sheets, which are modal overlays rather than routes. */ +export const Sheet = forwardRef<BottomSheet, SheetProps>(function Sheet( + { children, snapPoints = ['50%', '90%'], onDismiss }, + ref, +) { + const { colors, radius } = useTheme(); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} /> + ), + [], + ); + + return ( + <BottomSheet + ref={ref} + index={-1} + snapPoints={snapPoints} + enablePanDownToClose + onClose={onDismiss} + backdropComponent={renderBackdrop} + backgroundStyle={{ backgroundColor: colors.surface1, borderRadius: radius.xl }} + handleIndicatorStyle={{ backgroundColor: colors.borderSubtle }} + > + <BottomSheetView style={{ flex: 1 }}>{children}</BottomSheetView> + </BottomSheet> + ); +}); + +interface SheetModalProps extends PropsWithChildren { + snapPoints?: (string | number)[]; + onDismiss?: () => void; +} + +/** Modal variant using `@gorhom/bottom-sheet`'s `BottomSheetModal` — renders in a portal above + * everything else (including the tab bar), unlike the inline `Sheet` above which is constrained + * to wherever it's mounted in the tree. Requires a `BottomSheetModalProvider` ancestor (already + * mounted once at the app root in `src/app/_layout.tsx`). Use this for sheets opened from screens + * nested under the tab navigator that need to visually overlay the tab bar (field editors, + * composer action sheets, etc). */ +export const SheetModal = forwardRef<BottomSheetModal, SheetModalProps>(function SheetModal( + { children, snapPoints = ['50%', '90%'], onDismiss }, + ref, +) { + const { colors, radius } = useTheme(); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} /> + ), + [], + ); + + return ( + <BottomSheetModal + ref={ref} + index={0} + snapPoints={snapPoints} + enablePanDownToClose + onDismiss={onDismiss} + backdropComponent={renderBackdrop} + backgroundStyle={{ backgroundColor: colors.surface1, borderRadius: radius.xl }} + handleIndicatorStyle={{ backgroundColor: colors.borderSubtle }} + > + <BottomSheetView style={{ flex: 1 }}>{children}</BottomSheetView> + </BottomSheetModal> + ); +}); + +interface SheetHeaderProps { + title: string; + onClose?: () => void; + trailing?: ReactNode; +} + +/** Standard sheet header row: title, optional trailing slot, optional close button. Not required + * — sheets with fully custom headers can skip it. */ +export function SheetHeader({ title, onClose, trailing }: SheetHeaderProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + + return ( + <View + style={[ + styles.header, + { paddingHorizontal: spacing.md, paddingBottom: spacing.sm, gap: spacing.sm }, + ]} + > + <Text variant="title" style={styles.title} numberOfLines={1}> + {title} + </Text> + {trailing} + {onClose ? ( + <IconButton + accessibilityLabel={t('common.close', { defaultValue: 'Close' })} + icon="close" + size="sm" + variant="tonal" + onPress={onClose} + /> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + }, + title: { + flex: 1, + }, +}); diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..75be8c5 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,115 @@ +import { useEffect } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +interface SkeletonProps { + width?: number | `${number}%`; + height?: number; + borderRadius?: number; + style?: StyleProp<ViewStyle>; +} + +const PULSE_DURATION_MS = 800; + +/** Pulsing placeholder block for loading states. Compositor-friendly (opacity only). */ +export function Skeleton({ width = '100%', height = 16, borderRadius = 6, style }: SkeletonProps) { + const { colors } = useTheme(); + const reducedMotion = useReducedMotion(); + const opacity = useSharedValue(0.5); + + useEffect(() => { + if (reducedMotion) { + opacity.value = 0.7; + return undefined; + } + opacity.value = withRepeat( + withTiming(1, { duration: PULSE_DURATION_MS, easing: Easing.inOut(Easing.ease) }), + -1, + true, + ); + return () => cancelAnimation(opacity); + }, [opacity, reducedMotion]); + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + return ( + <Animated.View + style={[ + { width, height, borderRadius, backgroundColor: colors.surface2 }, + animatedStyle, + style, + ]} + /> + ); +} + +const rowStyles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + gap: 12, + }, + body: { + flex: 1, + gap: 6, + }, +}); + +/** Loading-state row shaped like a conversation list row: avatar circle + two lines of text + + * a small trailing meta block. Used by the inbox skeleton list. */ +function ConversationRow() { + return ( + <View style={rowStyles.row}> + <Skeleton width={52} height={52} borderRadius={26} /> + <View style={rowStyles.body}> + <Skeleton width="60%" height={16} /> + <Skeleton width="90%" height={13} /> + </View> + <Skeleton width={32} height={11} /> + </View> + ); +} + +/** Loading-state row shaped like a contact list row: avatar circle + name + subtitle line. */ +function ContactRow() { + return ( + <View style={rowStyles.row}> + <Skeleton width={44} height={44} borderRadius={22} /> + <View style={rowStyles.body}> + <Skeleton width="50%" height={16} /> + <Skeleton width="70%" height={13} /> + </View> + </View> + ); +} + +/** Loading-state placeholder for a single chat message bubble — a rounded block roughly the size + * of a short/medium message. `align` picks which side it renders on. */ +function MessageRow({ align = 'start' }: { align?: 'start' | 'end' }) { + return ( + <View style={[rowStyles.row, { justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }]}> + <Skeleton width={align === 'end' ? '45%' : '60%'} height={40} borderRadius={18} /> + </View> + ); +} + +/** Preset loading-row shapes for the three list contexts that need skeleton placeholders: + * conversations, contacts, and chat messages. Generic enough for any list screen to + * `.map()` a handful of these while data loads. */ +export const SkeletonRow = { + Conversation: ConversationRow, + Contact: ContactRow, + Message: MessageRow, +}; diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx new file mode 100644 index 0000000..523571a --- /dev/null +++ b/src/components/ui/surface.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { StyleSheet, View } from 'react-native'; + +import type { radiusTokens, spacingTokens } from '@/theme/tokens'; +import { useTheme } from '@/theme/use-theme'; + +interface SurfaceProps extends PropsWithChildren { + /** Which raised surface level to sit on — 0 is the canvas-adjacent level, 1/2 progressively + * more raised. Defaults to 0 (flush with the canvas). */ + level?: 0 | 1 | 2; + radius?: keyof typeof radiusTokens; + bordered?: boolean; + elevation?: 0 | 1 | 2 | 3; + padding?: keyof typeof spacingTokens; + style?: StyleProp<ViewStyle>; +} + +const SURFACE_COLOR_KEYS = ['surface0', 'surface1', 'surface2'] as const; + +/** Generic raised-surface container: resolves `level` to the matching `surfaceN` token, applies + * radius/border/elevation/padding from token keys instead of raw values. Use `Card` below for the + * common bordered-elevated-card shape. */ +export function Surface({ + level = 0, + radius, + bordered = false, + elevation = 0, + padding, + style, + children, +}: SurfaceProps) { + const theme = useTheme(); + const { colors, radius: radiusScale, elevation: elevationScale, spacing } = theme; + + const backgroundColor = colors[SURFACE_COLOR_KEYS[level]]; + const elevationStyle = elevationScale[elevation]; + + return ( + <View + style={[ + { + backgroundColor, + borderRadius: radius ? radiusScale[radius] : undefined, + borderWidth: bordered ? StyleSheet.hairlineWidth : undefined, + borderColor: bordered ? colors.borderSubtle : undefined, + padding: padding ? spacing[padding] : undefined, + }, + elevation > 0 ? elevationStyle : undefined, + style, + ]} + > + {children} + </View> + ); +} + +/** Preconfigured Surface for the common "card" shape used across rows/panels: level 1, bordered, + * medium radius, subtle elevation. Any prop can still be overridden by the caller. */ +export function Card(props: SurfaceProps) { + return <Surface level={1} bordered radius="md" elevation={1} {...props} />; +} diff --git a/src/components/ui/swipeable-row.tsx b/src/components/ui/swipeable-row.tsx new file mode 100644 index 0000000..a8c46cc --- /dev/null +++ b/src/components/ui/swipeable-row.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from 'react'; +import { useRef } from 'react'; +import { I18nManager, Pressable, StyleSheet, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import Swipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; + +import { triggerHaptic } from '@/lib/haptics'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon, type IconName } from './icon'; +import { Text } from './text'; + +export interface SwipeAction { + icon: IconName; + label: string; + color: string; + onPress: () => void; + destructive?: boolean; +} + +interface SwipeableRowProps { + leftActions?: SwipeAction[]; + rightActions?: SwipeAction[]; + onSwipeOpen?: (side: 'left' | 'right') => void; + children: ReactNode; +} + +const ACTION_WIDTH = 76; + +function ActionButton({ action, width }: { action: SwipeAction; width: number }) { + const { colors, spacing } = useTheme(); + + return ( + <Pressable + accessibilityRole="button" + accessibilityLabel={action.label} + onPress={action.onPress} + style={[ + styles.action, + { + width, + backgroundColor: action.destructive ? colors.danger : action.color, + gap: spacing.xxs, + }, + ]} + > + <Icon name={action.icon} size={20} color={colors.onBrand} /> + <Text variant="micro" style={{ color: colors.onBrand, fontWeight: '600' }} numberOfLines={1}> + {action.label} + </Text> + </Pressable> + ); +} + +function ActionsPanel({ actions, side }: { actions: SwipeAction[]; side: 'left' | 'right' }) { + const width = ACTION_WIDTH * actions.length; + // In RTL, flip which physical side "start" actions render on so they stay at the reading + // start of the row, matching the swap the plan calls for. + const isRTL = I18nManager.isRTL; + const renderSide = isRTL ? (side === 'left' ? 'right' : 'left') : side; + + return ( + <View + style={[ + styles.actionsPanel, + { width }, + renderSide === 'left' ? styles.actionsLeft : styles.actionsRight, + ]} + > + {actions.map((action) => ( + <ActionButton key={action.label} action={action} width={ACTION_WIDTH} /> + ))} + </View> + ); +} + +/** Row wrapper with swipe-to-reveal action panels on either edge. Wraps RNGH's + * `ReanimatedSwipeable`. `leftActions`/`rightActions` are logical (start/end in LTR); when + * `I18nManager.isRTL` is true, the rendered panel sides swap so "start" actions stay at the + * visual start of the row. Fires a 'medium' haptic once a swipe commits to opening — driven by + * RNGH's own `onSwipeableWillOpen` callback (fired once, off the UI thread via `runOnJS` + * internally) rather than a `useAnimatedReaction` tracking a shared value that + * `renderLeftActions`/`renderRightActions` mutated during render — mutating a shared value as a + * side effect of a render-phase function is exactly the kind of thing React (and reanimated) call + * out as unsafe, even though it "worked" in practice here. */ +export function SwipeableRow({ + leftActions, + rightActions, + onSwipeOpen, + children, +}: SwipeableRowProps) { + const swipeableRef = useRef<SwipeableMethods>(null); + + const renderLeftActions = leftActions?.length + ? (progress: SharedValue<number>) => <ActionsPanel actions={leftActions} side="left" /> + : undefined; + + const renderRightActions = rightActions?.length + ? (progress: SharedValue<number>) => <ActionsPanel actions={rightActions} side="right" /> + : undefined; + + return ( + <Swipeable + ref={swipeableRef} + friction={2} + leftThreshold={ACTION_WIDTH / 2} + rightThreshold={ACTION_WIDTH / 2} + renderLeftActions={I18nManager.isRTL ? renderRightActions : renderLeftActions} + renderRightActions={I18nManager.isRTL ? renderLeftActions : renderRightActions} + onSwipeableWillOpen={() => triggerHaptic('medium')} + onSwipeableOpen={(direction) => { + // RNGH reports the physically-rendered side; translate back to logical start/end. + const logicalSide = I18nManager.isRTL + ? direction === 'left' + ? 'right' + : 'left' + : direction; + onSwipeOpen?.(logicalSide); + }} + > + {children} + </Swipeable> + ); +} + +const styles = StyleSheet.create({ + actionsPanel: { + flexDirection: 'row', + }, + actionsLeft: { + justifyContent: 'flex-start', + }, + actionsRight: { + justifyContent: 'flex-end', + }, + action: { + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/components/ui/text-field.tsx b/src/components/ui/text-field.tsx new file mode 100644 index 0000000..0725ce3 --- /dev/null +++ b/src/components/ui/text-field.tsx @@ -0,0 +1,117 @@ +import { forwardRef, useState } from 'react'; +import type { TextInput as RNTextInputType } from 'react-native'; +import { StyleSheet, TextInput, View } from 'react-native'; + +import { useTheme } from '@/theme/use-theme'; + +import { IconButton } from './icon-button'; +import type { IconName } from './icon'; +import { Text } from './text'; + +interface TextFieldProps extends React.ComponentProps<typeof TextInput> { + label?: string; + error?: string; + /** Renders an IconButton inside the field's trailing edge (e.g. a password show/hide toggle). + * Purely additive to the base field — omit both props for the original single-input layout. */ + trailingIcon?: IconName; + /** Required when `trailingIcon` is set — forwarded to the inner IconButton's + * `accessibilityLabel` so the toggle is independently announced by screen readers. */ + trailingIconAccessibilityLabel?: string; + onTrailingIconPress?: () => void; +} + +export const TextField = forwardRef<RNTextInputType, TextFieldProps>(function TextField( + { + label, + error, + trailingIcon, + trailingIconAccessibilityLabel, + onTrailingIconPress, + style, + onFocus, + onBlur, + ...rest + }, + ref, +) { + const { colors, spacing, radius, typography, scheme } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + return ( + <View style={styles.container}> + {label ? ( + <Text variant="caption" color="secondary" style={styles.label}> + {label} + </Text> + ) : null} + <View style={styles.inputRow}> + <TextInput + ref={ref} + placeholderTextColor={colors.textSecondary} + keyboardAppearance={scheme} + onFocus={(e) => { + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} + style={[ + styles.input, + { + backgroundColor: colors.surface2, + borderColor: error ? colors.danger : isFocused ? colors.brand : colors.borderSubtle, + borderRadius: radius.md, + color: colors.textPrimary, + fontSize: typography.body.fontSize, + paddingHorizontal: spacing.base, + paddingEnd: trailingIcon ? spacing.xl + spacing.sm : spacing.base, + }, + style, + ]} + {...rest} + /> + {trailingIcon ? ( + <View style={styles.trailingIcon}> + <IconButton + accessibilityLabel={trailingIconAccessibilityLabel ?? ''} + icon={trailingIcon} + size="sm" + variant="ghost" + onPress={onTrailingIconPress} + /> + </View> + ) : null} + </View> + {error ? ( + <Text variant="caption" color="danger" style={styles.error}> + {error} + </Text> + ) : null} + </View> + ); +}); + +const styles = StyleSheet.create({ + container: { + gap: 4, + }, + label: { + marginStart: 2, + }, + inputRow: { + justifyContent: 'center', + }, + input: { + height: 48, + borderWidth: StyleSheet.hairlineWidth, + }, + trailingIcon: { + position: 'absolute', + end: 4, + }, + error: { + marginStart: 2, + }, +}); diff --git a/src/components/ui/text.tsx b/src/components/ui/text.tsx new file mode 100644 index 0000000..2d833d7 --- /dev/null +++ b/src/components/ui/text.tsx @@ -0,0 +1,100 @@ +import type { ComponentProps } from 'react'; +import { useMemo } from 'react'; +import { Text as RNText, StyleSheet } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { resolveDisplayFamily } from '@/theme/typography'; +import { useTheme } from '@/theme/use-theme'; +import type { typographyTokens } from '@/theme/tokens'; + +type Variant = keyof typeof typographyTokens; +type ColorRole = + | 'primary' + | 'secondary' + | 'tertiary' + | 'inverse' + | 'brand' + | 'danger' + | 'success' + | 'warning' + | 'onBrand'; + +/** Variants whose weight (600/700) qualifies as "chrome" text — these get the Plus Jakarta Sans + * display family (language permitting); body/callout/caption/micro stay on the system font since + * they render user-generated content. */ +const DISPLAY_FAMILY_VARIANTS: readonly Variant[] = [ + 'display', + 'heading', + 'title', + 'subtitle', + 'label', +]; + +interface TextProps extends ComponentProps<typeof RNText> { + variant?: Variant; + color?: ColorRole; + /** Applies tabular-nums font variant for aligned numeric columns (counts, timestamps). */ + numeric?: boolean; +} + +/** Themed text primitive: variant maps to typographyTokens, color maps to a semantic palette + * role instead of a raw hex value. Display-weight variants resolve a locale-aware display font + * family; `maxFontSizeMultiplier` is read from the variant token so Dynamic Type scaling stays + * within a sane cap per text role. */ +export function Text({ + variant = 'body', + color = 'primary', + numeric = false, + style, + ...rest +}: TextProps) { + const { colors, typography } = useTheme(); + const { i18n } = useTranslation(); + + // Hoisted behind useMemo — this component renders on every message/list row, so rebuilding a + // fresh object every render (to read a single value out of it) was needless per-render churn. + const colorMap: Record<ColorRole, string> = useMemo( + () => ({ + primary: colors.textPrimary, + secondary: colors.textSecondary, + tertiary: colors.textTertiary, + inverse: colors.textInverse, + brand: colors.brand, + danger: colors.danger, + success: colors.success, + warning: colors.warning, + onBrand: colors.onBrand, + }), + [colors], + ); + + const token = typography[variant]; + const fontFamily = DISPLAY_FAMILY_VARIANTS.includes(variant) + ? resolveDisplayFamily(i18n.language, token.fontWeight === '700' ? '700' : '600') + : undefined; + + return ( + <RNText + maxFontSizeMultiplier={token.maxFontSizeMultiplier} + style={[ + styles.base, + { + fontSize: token.fontSize, + lineHeight: token.lineHeight, + fontWeight: token.fontWeight, + color: colorMap[color], + fontFamily, + textTransform: 'textTransform' in token ? token.textTransform : undefined, + letterSpacing: 'letterSpacing' in token ? token.letterSpacing : undefined, + fontVariant: numeric ? ['tabular-nums'] : undefined, + }, + style, + ]} + {...rest} + /> + ); +} + +const styles = StyleSheet.create({ + base: {}, +}); diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..b1b3a1f --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,212 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +import { Icon } from './icon'; +import { Text } from './text'; + +type Tone = 'neutral' | 'success' | 'danger' | 'info'; + +interface ToastOptions { + message: string; + tone?: Tone; + action?: { label: string; onPress: () => void }; + durationMs?: number; +} + +interface QueuedToast extends ToastOptions { + id: number; +} + +type ShowToast = (opts: ToastOptions) => void; + +const ToastContext = createContext<ShowToast | null>(null); + +const DEFAULT_DURATION_MS = 3000; +const TOAST_HEIGHT = 48; + +/** Mount once near the app root. Provides `useToast()` to any descendant; renders a single toast + * at a time from a simple FIFO queue — a new toast while one is showing waits its turn rather + * than stacking or replacing. The head of `queue` (index 0) is always the currently-displayed + * toast, so there's no separate "current" state to keep in sync — dismissing just shifts the + * array, which is itself the transition to whatever's next. */ +export function ToastProvider({ children }: PropsWithChildren) { + const insets = useSafeAreaInsets(); + const { colors, radius, spacing, motion } = useTheme(); + const reducedMotion = useReducedMotion(); + + const [queue, setQueue] = useState<QueuedToast[]>([]); + const nextId = useRef(0); + const translateY = useSharedValue(TOAST_HEIGHT + 40); + const opacity = useSharedValue(0); + + const current = queue[0] ?? null; + + const show = useCallback<ShowToast>((opts) => { + nextId.current += 1; + setQueue((prev) => [...prev, { ...opts, id: nextId.current }]); + }, []); + + const dismissCurrent = useCallback(() => { + setQueue((prev) => prev.slice(1)); + }, []); + + // Animate the current toast in, then out after its duration, then dismiss it so the next + // queued toast (if any) becomes `current` on the following render. + useEffect(() => { + if (!current) return undefined; + + if (reducedMotion) { + translateY.value = 0; + opacity.value = 1; + } else { + translateY.value = withTiming(0, { duration: motion.durations.enter }); + opacity.value = withTiming(1, { duration: motion.durations.enter }); + } + + const duration = current.durationMs ?? DEFAULT_DURATION_MS; + // Both timers are tracked (not just the outer one) so cleanup can cancel the exit-animation + // dismiss too — previously only the outer timer was cleared, so if `current` changed (or this + // unmounted) mid-exit-animation, the inner `setTimeout` still fired later and called + // `dismissCurrent` against a stale render. + let exitTimer: ReturnType<typeof setTimeout> | undefined; + const timer = setTimeout(() => { + if (reducedMotion) { + translateY.value = TOAST_HEIGHT + 40; + opacity.value = 0; + dismissCurrent(); + } else { + opacity.value = withTiming(0, { duration: motion.durations.exit }); + translateY.value = withTiming(TOAST_HEIGHT + 40, { duration: motion.durations.exit }); + exitTimer = setTimeout(dismissCurrent, motion.durations.exit); + } + }, duration); + + return () => { + clearTimeout(timer); + clearTimeout(exitTimer); + }; + }, [ + current, + dismissCurrent, + reducedMotion, + motion.durations.enter, + motion.durations.exit, + translateY, + opacity, + ]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + const toneColor: Record<Tone, string> = { + neutral: colors.textInverse, + success: colors.success, + danger: colors.danger, + info: colors.info, + }; + + return ( + <ToastContext.Provider value={show}> + {children} + {current ? ( + <Animated.View + pointerEvents="box-none" + style={[ + styles.host, + { bottom: insets.bottom + spacing.md }, + reducedMotion ? undefined : animatedStyle, + ]} + > + <Animated.View + accessibilityLiveRegion="polite" + accessibilityRole="alert" + style={[ + styles.toast, + { + backgroundColor: colors.textPrimary, + borderRadius: radius.md, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + gap: spacing.sm, + }, + ]} + > + {current.tone && current.tone !== 'neutral' ? ( + <Icon + name={ + current.tone === 'success' + ? 'checkmark-circle' + : current.tone === 'danger' + ? 'alert-circle' + : 'information-circle' + } + size={18} + color={toneColor[current.tone]} + /> + ) : null} + <Text variant="callout" color="inverse" style={styles.message} numberOfLines={2}> + {current.message} + </Text> + {current.action ? ( + <Text + variant="callout" + color="inverse" + style={styles.action} + onPress={() => { + current.action?.onPress(); + dismissCurrent(); + }} + > + {current.action.label} + </Text> + ) : null} + </Animated.View> + </Animated.View> + ) : null} + </ToastContext.Provider> + ); +} + +/** Returns a function to enqueue a toast. Must be called under a `ToastProvider`. */ +export function useToast(): ShowToast { + const show = useContext(ToastContext); + if (!show) { + throw new Error('useToast must be used within a ToastProvider'); + } + return show; +} + +const styles = StyleSheet.create({ + host: { + position: 'absolute', + start: 16, + end: 16, + alignItems: 'center', + }, + toast: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }, + message: { + flex: 1, + }, + action: { + fontWeight: '700', + }, +}); diff --git a/src/components/ui/toggle-row.test.tsx b/src/components/ui/toggle-row.test.tsx new file mode 100644 index 0000000..47c65a1 --- /dev/null +++ b/src/components/ui/toggle-row.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; + +import { ToggleRow } from './toggle-row'; + +beforeAll(() => { + initI18n(); +}); + +describe('ToggleRow', () => { + it('exposes switch role and checked accessibility state', async () => { + await render(<ToggleRow label="Push notifications" value onValueChange={() => {}} />); + + const control = screen.getByRole('switch', { name: 'Push notifications' }); + expect(control.props.accessibilityState).toMatchObject({ checked: true }); + }); + + it('reflects unchecked state', async () => { + await render(<ToggleRow label="Push notifications" value={false} onValueChange={() => {}} />); + + const control = screen.getByRole('switch', { name: 'Push notifications' }); + expect(control.props.accessibilityState).toMatchObject({ checked: false }); + }); + + it('calls onValueChange with the new value when toggled', async () => { + const onValueChange = jest.fn(); + await render( + <ToggleRow label="Push notifications" value={false} onValueChange={onValueChange} />, + ); + + fireEvent(screen.getByRole('switch', { name: 'Push notifications' }), 'valueChange', true); + + expect(onValueChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/components/ui/toggle-row.tsx b/src/components/ui/toggle-row.tsx new file mode 100644 index 0000000..b549646 --- /dev/null +++ b/src/components/ui/toggle-row.tsx @@ -0,0 +1,58 @@ +import { StyleSheet, Switch, View } from 'react-native'; + +import { triggerHaptic } from '@/lib/haptics'; +import { useTheme } from '@/theme/use-theme'; + +import { Text } from './text'; + +interface ToggleRowProps { + label: string; + description?: string; + value: boolean; + onValueChange: (value: boolean) => void; + disabled?: boolean; +} + +export function ToggleRow({ label, description, value, onValueChange, disabled }: ToggleRowProps) { + const { colors, spacing } = useTheme(); + + function handleChange(next: boolean) { + triggerHaptic('selection'); + onValueChange(next); + } + + return ( + <View style={[styles.row, { paddingVertical: spacing.ms, paddingHorizontal: spacing.md }]}> + <View style={styles.body}> + <Text variant="body">{label}</Text> + {description ? ( + <Text variant="caption" color="secondary"> + {description} + </Text> + ) : null} + </View> + <Switch + accessibilityLabel={label} + accessibilityRole="switch" + accessibilityState={{ checked: value, disabled }} + value={value} + onValueChange={handleChange} + disabled={disabled} + trackColor={{ true: colors.brand, false: colors.borderSubtle }} + /> + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + body: { + flex: 1, + gap: 2, + marginEnd: 12, + }, +}); diff --git a/src/components/workspace-blocked-gate.tsx b/src/components/workspace-blocked-gate.tsx new file mode 100644 index 0000000..0ea461a --- /dev/null +++ b/src/components/workspace-blocked-gate.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { useSwitchWorkspace } from '@/features/workspaces/api/use-switch-workspace'; +import { useTheme } from '@/theme/use-theme'; + +import { Button } from './ui/button'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface WorkspaceBlockedGateProps { + reason: 'mac' | 'trialExpired' | 'unknown'; + message: string; +} + +/** + * Full-screen state for a 402 `workspaceBlocked` error on a workspace-scoped screen query (e.g. + * the conversations list itself failing, not just a composer send — quota-banner.tsx already + * handles the send-time case locally). Distinguishes MAC-limit vs trial-expired copy, with a + * "switch workspace" escape hatch mirroring the settings screen's own switch flow. + */ +export function WorkspaceBlockedGate({ reason, message }: WorkspaceBlockedGateProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const switchWorkspace = useSwitchWorkspace(); + + const copy = + reason === 'mac' + ? t('errors.workspaceBlockedMac') + : reason === 'trialExpired' + ? t('errors.workspaceBlockedTrialExpired') + : message; + + function handleSwitchWorkspace() { + switchWorkspace(null); + } + + return ( + <View + style={{ + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + gap: spacing.md, + }} + > + <Icon name="lock-closed-outline" size={40} color={colors.textSecondary} /> + <Text variant="title" style={{ textAlign: 'center' }}> + {t('errors.workspaceBlockedTitle')} + </Text> + <Text variant="body" color="secondary" style={{ textAlign: 'center' }}> + {copy} + </Text> + <Button label={t('settings.switchWorkspace')} onPress={handleSwitchWorkspace} /> + </View> + ); +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..76dbd7b --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,29 @@ +import Constants from 'expo-constants'; + +interface AppExtraConfig { + apiBaseUrl: string; + wsUrl: string; +} + +function readExtra(): AppExtraConfig { + const extra = Constants.expoConfig?.extra as Partial<AppExtraConfig> | undefined; + + if (!extra?.apiBaseUrl) { + throw new Error( + 'Missing "extra.apiBaseUrl" in Expo config. Check app.config.ts and the API_BASE_URL env var.', + ); + } + if (!extra?.wsUrl) { + throw new Error( + 'Missing "extra.wsUrl" in Expo config. Check app.config.ts and the WS_URL env var.', + ); + } + + return { apiBaseUrl: extra.apiBaseUrl, wsUrl: extra.wsUrl }; +} + +/** + * Tenant-defining, env-driven app configuration. Sourced from app.config.ts `extra`, which in + * turn reads `process.env.API_BASE_URL`/`WS_URL` at Expo config evaluation time. + */ +export const env: AppExtraConfig = readExtra(); diff --git a/src/config/features.ts b/src/config/features.ts new file mode 100644 index 0000000..8ca5468 --- /dev/null +++ b/src/config/features.ts @@ -0,0 +1,12 @@ +/** + * Client-side feature flags. No env override wiring exists yet — `app.config.ts`'s `extra` shape + * has no `features` key, so this is a hardcoded constant until that's worth adding. + * + * `sendSequence`: the backend route needed to enroll a contact in a sequence + * (`POST /workspaces/{workspaceId}/contacts/sequences`) doesn't exist yet — see + * `src/features/sequences/api/use-enroll-contact-in-sequences.ts`. Flip to `true` once that route + * ships and the hand-rolled fetch there is replaced with the generated client. + */ +export const FEATURES = { + sendSequence: false, +} as const; diff --git a/src/dev/gallery-content.tsx b/src/dev/gallery-content.tsx new file mode 100644 index 0000000..6e5d057 --- /dev/null +++ b/src/dev/gallery-content.tsx @@ -0,0 +1,427 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import type { ReactNode } from 'react'; +import { useRef, useState } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, Surface } from '@/components/ui/surface'; +import { Chip } from '@/components/ui/chip'; +import { ConnectionBanner, type ConnectionState } from '@/components/ui/connection-banner'; +import { CountPill } from '@/components/ui/count-pill'; +import { DateSeparator } from '@/components/ui/date-separator'; +import { Divider } from '@/components/ui/divider'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { IconButton } from '@/components/ui/icon-button'; +import { ListFooterSpinner } from '@/components/ui/list-footer-spinner'; +import { ListItem } from '@/components/ui/list-item'; +import { Screen } from '@/components/ui/screen'; +import { ScreenHeader } from '@/components/ui/screen-header'; +import { SearchBar } from '@/components/ui/search-bar'; +import { SegmentedTabs } from '@/components/ui/segmented-tabs'; +import { Sheet, SheetHeader } from '@/components/ui/sheet'; +import { Skeleton, SkeletonRow } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { ToggleRow } from '@/components/ui/toggle-row'; +import { useToast } from '@/components/ui/toast'; +import { useSettingsStore } from '@/stores/use-settings-store'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Visual smoke-test surface for every UI primitive, in various states, with a light/dark + * toggle. Not a polished screen — a "storybook-lite". Split into its own + * module (rather than living inline in the `(dev)/gallery` route file) so its 24 primitive imports + * are only pulled into the bundle via the lazy `require()` in that route's `__DEV__` branch, + * instead of shipping in every production build's static import graph. + */ +export function GalleryContent() { + const { colors, spacing } = useTheme(); + const themePreference = useSettingsStore((state) => state.themePreference); + const setThemePreference = useSettingsStore((state) => state.setThemePreference); + const showToast = useToast(); + + const [search, setSearch] = useState(''); + const [tab, setTab] = useState<'all' | 'unread' | 'bot'>('all'); + const [chipSelected, setChipSelected] = useState(true); + const [toggleValue, setToggleValue] = useState(true); + const [connectionState, setConnectionState] = useState<ConnectionState>('offline'); + const sheetRef = useRef<BottomSheet>(null); + // Lazy-init so `Date.now()` runs once (at first render) instead of being called fresh on every + // render, which the react-compiler lint rule flags as an impure render-time call. + const [{ todayIso, yesterdayIso }] = useState(() => { + const now = Date.now(); + return { + todayIso: new Date(now).toISOString(), + yesterdayIso: new Date(now - 86400000).toISOString(), + }; + }); + + function toggleScheme() { + setThemePreference(themePreference === 'dark' ? 'light' : 'dark'); + } + + return ( + <Screen edges={['top', 'bottom']}> + <ScrollView contentContainerStyle={{ padding: spacing.md, gap: spacing.lg }}> + <ScreenHeader + title="UI Gallery" + subtitle="Dev-only primitive smoke test" + trailing={ + <IconButton + accessibilityLabel="Toggle color scheme" + icon={themePreference === 'dark' ? 'sunny-outline' : 'moon-outline'} + variant="tonal" + onPress={toggleScheme} + /> + } + /> + + <Section title="Text"> + <Text variant="display">Display</Text> + <Text variant="heading">Heading</Text> + <Text variant="title">Title</Text> + <Text variant="subtitle">Subtitle</Text> + <Text variant="body">Body text</Text> + <Text variant="bodyStrong">Body strong</Text> + <Text variant="callout">Callout</Text> + <Text variant="caption" color="secondary"> + Caption secondary + </Text> + <Text variant="label">Label</Text> + <Text variant="micro" numeric> + 123,456 + </Text> + </Section> + + <Section title="Buttons"> + <View style={styles.row}> + <Button label="Primary" onPress={() => {}} /> + <Button label="Tonal" variant="tonal" onPress={() => {}} /> + <Button label="Secondary" variant="secondary" onPress={() => {}} /> + </View> + <View style={styles.row}> + <Button label="Danger" variant="danger" onPress={() => {}} /> + <Button label="Ghost" variant="ghost" onPress={() => {}} /> + <Button label="Icon" icon="send" onPress={() => {}} /> + </View> + <View style={styles.row}> + <Button label="Small" size="sm" onPress={() => {}} /> + <Button label="Loading" loading onPress={() => {}} /> + <Button label="Disabled" disabled onPress={() => {}} /> + </View> + </Section> + + <Section title="IconButton"> + <View style={styles.row}> + <IconButton + accessibilityLabel="Ghost" + icon="heart-outline" + variant="ghost" + onPress={() => {}} + /> + <IconButton + accessibilityLabel="Tonal" + icon="heart-outline" + variant="tonal" + onPress={() => {}} + /> + <IconButton + accessibilityLabel="Filled" + icon="heart-outline" + variant="filled" + onPress={() => {}} + /> + <IconButton + accessibilityLabel="Outline" + icon="heart-outline" + variant="outline" + onPress={() => {}} + /> + <IconButton + accessibilityLabel="Badged" + icon="notifications-outline" + badgeCount={5} + onPress={() => {}} + /> + <IconButton accessibilityLabel="Loading" icon="send" loading onPress={() => {}} /> + </View> + </Section> + + <Section title="Avatar"> + <View style={styles.row}> + <Avatar name="Ada Lovelace" size={40} /> + <Avatar name="Grace Hopper" size={52} ring="online" /> + <Avatar name="Bot Assistant" size={52} ring="bot" /> + <Avatar name="Margaret Hamilton" size={52} badge={<Badge tone="success" count={3} />} /> + </View> + </Section> + + <Section title="Chip"> + <View style={styles.row}> + <Chip label="All" selected={tab === 'all'} onPress={() => setTab('all')} /> + <Chip label="Unread" selected={tab === 'unread'} onPress={() => setTab('unread')} /> + <Chip + label="Bot" + tone="custom" + color={colors.bubbleBotAccent} + selected={chipSelected} + onPress={() => setChipSelected((prev) => !prev)} + /> + <Chip label="VIP" onRemove={() => {}} /> + </View> + </Section> + + <Section title="Badge / CountPill"> + <View style={styles.row}> + <Badge tone="brand">New</Badge> + <Badge tone="danger" count={12} /> + <CountPill count={4} /> + <CountPill count={230} tone="danger" /> + </View> + </Section> + + <Section title="Surface / Card"> + <Surface level={1} radius="md" padding="md" bordered> + <Text variant="body">Surface level 1</Text> + </Surface> + <Card padding="md" elevation={2}> + <Text variant="body">Card (level 1, bordered, elevation 2)</Text> + </Card> + </Section> + + <Section title="SearchBar / SegmentedTabs"> + <SearchBar value={search} onChangeText={setSearch} placeholder="Search…" /> + <SegmentedTabs + options={[ + { value: 'all', label: 'All' }, + { value: 'unread', label: 'Unread' }, + { value: 'bot', label: 'Bot' }, + ]} + value={tab} + onChange={setTab} + /> + </Section> + + <Section title="ListItem"> + <Card padding="xxs"> + <ListItem + title="Standard row" + subtitle="With a subtitle" + showChevron + onPress={() => {}} + /> + <Divider inset /> + <ListItem title="Row with value" value="Enabled" onPress={() => {}} /> + <Divider inset /> + <ListItem title="Destructive row" destructive onPress={() => {}} /> + <Divider inset /> + <ListItem title="Disabled row" disabled onPress={() => {}} /> + </Card> + </Section> + + <Section title="ToggleRow"> + <Card padding="xxs"> + <ToggleRow + label="Push notifications" + description="Get notified about new messages" + value={toggleValue} + onValueChange={setToggleValue} + /> + </Card> + </Section> + + <Section title="ErrorBanner"> + <ErrorBanner + tone="danger" + message="Failed to send message." + actionLabel="Retry" + onAction={() => {}} + /> + <ErrorBanner tone="warning" message="This workspace is near its plan limit." /> + <ErrorBanner tone="info" message="You're viewing a read-only flow." /> + </Section> + + <Section title="EmptyState"> + <View style={{ height: 220 }}> + <EmptyState + icon="chatbubble-ellipses-outline" + title="No conversations" + description="New conversations will show up here." + action={{ label: 'Clear filters', onPress: () => {} }} + /> + </View> + </Section> + + <Section title="Skeleton"> + <SkeletonRow.Conversation /> + <SkeletonRow.Contact /> + <SkeletonRow.Message align="start" /> + <SkeletonRow.Message align="end" /> + <Skeleton width="80%" height={14} /> + </Section> + + <Section title="ListFooterSpinner"> + <Card padding="xxs"> + <ListFooterSpinner visible /> + </Card> + </Section> + + <Section title="DateSeparator"> + <DateSeparator date={todayIso} /> + <DateSeparator date={yesterdayIso} /> + </Section> + + <Section title="ConnectionBanner"> + <ConnectionBanner state={connectionState} /> + <View style={styles.row}> + {(['online', 'connecting', 'reconnecting', 'offline'] as ConnectionState[]).map( + (state) => ( + <Button + key={state} + label={state} + size="sm" + variant="secondary" + onPress={() => setConnectionState(state)} + /> + ), + )} + </View> + </Section> + + <Section title="Toast"> + <Button + label="Show toast" + variant="tonal" + onPress={() => + showToast({ + message: 'Message sent', + tone: 'success', + action: { label: 'Undo', onPress: () => {} }, + }) + } + /> + </Section> + + <Section title="Sheet"> + <Button + label="Open sheet" + variant="secondary" + onPress={() => sheetRef.current?.expand()} + /> + </Section> + + <Section title="Fake conversation row"> + <Card padding="md"> + <View style={[styles.row, { alignItems: 'flex-start', gap: spacing.sm }]}> + <Avatar name="Jane Doe" size={52} ring="online" /> + <View style={{ flex: 1, gap: 4 }}> + <View style={[styles.row, { justifyContent: 'space-between' }]}> + <Text variant="bodyStrong" numberOfLines={1}> + Jane Doe + </Text> + <Text variant="micro" color="tertiary" numeric> + 2m + </Text> + </View> + <Text variant="callout" color="secondary" numberOfLines={1}> + Thanks for the quick reply! Let me check with the team. + </Text> + <View style={styles.row}> + <Chip + label="Bot handling" + size="sm" + tone="custom" + color={colors.bubbleBotAccent} + /> + <CountPill count={2} /> + </View> + </View> + </View> + </Card> + </Section> + + <Section title="Fake message bubble group"> + <View style={{ gap: 4 }}> + <View style={[styles.row, { justifyContent: 'flex-start' }]}> + <View + style={{ + backgroundColor: colors.bubbleIn, + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + maxWidth: '75%', + }} + > + <Text variant="body" style={{ color: colors.bubbleInText }}> + Hey, is my order still on track? + </Text> + </View> + </View> + <View style={[styles.row, { justifyContent: 'flex-end' }]}> + <View + style={{ + backgroundColor: colors.bubbleOut, + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + maxWidth: '75%', + }} + > + <Text variant="body" style={{ color: colors.bubbleOutText }}> + Yes! It shipped this morning. + </Text> + </View> + </View> + <View style={[styles.row, { justifyContent: 'flex-start' }]}> + <View + style={{ + backgroundColor: colors.bubbleBot, + borderRadius: 18, + paddingHorizontal: 14, + paddingVertical: 10, + maxWidth: '75%', + }} + > + <Text variant="body" style={{ color: colors.bubbleBotText }}> + Automated: your tracking link has been sent by email. + </Text> + </View> + </View> + </View> + </Section> + </ScrollView> + + <Sheet ref={sheetRef} snapPoints={['40%']}> + <SheetHeader title="Example sheet" onClose={() => sheetRef.current?.close()} /> + <View style={{ padding: spacing.md }}> + <Text variant="body" color="secondary"> + This is a Sheet body, rendered via BottomSheetView. + </Text> + </View> + </Sheet> + </Screen> + ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + const { spacing } = useTheme(); + return ( + <View style={{ gap: spacing.sm }}> + <Text variant="label" color="tertiary"> + {title} + </Text> + <View style={{ gap: spacing.sm }}>{children}</View> + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 8, + }, +}); diff --git a/src/features/auth/social-buttons.tsx b/src/features/auth/social-buttons.tsx new file mode 100644 index 0000000..157b4f6 --- /dev/null +++ b/src/features/auth/social-buttons.tsx @@ -0,0 +1,102 @@ +import { router, type Href } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { getSession, SOCIAL_CALLBACK_URL, startSocialSignIn } from '@/api/auth-endpoints'; +import { getAuthToken } from '@/api/auth-token'; +import { Button } from '@/components/ui/button'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { consumePendingDeepLink } from '@/lib/pending-deep-link'; +import { useAuthStore } from '@/stores/use-auth-store'; + +type Provider = 'google' | 'facebook'; + +/** + * Social sign-in buttons. Flow: `startSocialSignIn` gets the provider authorize URL from + * better-auth, `WebBrowser.openAuthSessionAsync` opens it and resolves once the broker redirects + * back to our `chatbotxmobileapp://auth-callback` scheme (see auth-endpoints.ts for the + * trustedOrigins gap that currently blocks this redirect server-side — this UI is correct but + * won't complete end-to-end until that backend change lands). + * + * TOKEN HYDRATION — real, currently-unresolved gap: unlike email/password sign-in, which reads a + * bearer token straight off the `set-auth-token` response header of a same-origin fetch, the OAuth + * dance here happens inside `WebBrowser`'s own isolated browser session/cookie jar, not this app's + * fetch client. Nothing in that flow currently hands this app a bearer token: the callback URL + * `openAuthSessionAsync` resolves with is not parsed for one here (its shape/whether it carries a + * token at all is unverified — parsing an assumed shape would be worse than not assuming), and the + * `getSession()` call below only works if a token is ALREADY in SecureStore, which it isn't yet at + * this point in the flow. So this currently cannot complete sign-in even once trustedOrigins is + * fixed, UNLESS the backend's broker relay is changed to also forward a bearer token (e.g. as a + * `token` query param on the `chatbotxmobileapp://auth-callback` redirect, mirroring what + * `set-auth-token` does for same-origin requests) — or a dedicated mobile token-exchange endpoint + * is added. Neither is implemented here; both are backend-shaped decisions outside this repo. + */ +export function SocialButtons() { + const { t } = useTranslation(); + const setSignedIn = useAuthStore((state) => state.setSignedIn); + const [error, setError] = useState<string | null>(null); + const [loadingProvider, setLoadingProvider] = useState<Provider | null>(null); + + const handleSignIn = async (provider: Provider) => { + setError(null); + setLoadingProvider(provider); + try { + const authorizeUrl = await startSocialSignIn(provider); + const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, SOCIAL_CALLBACK_URL); + + if (result.type !== 'success') { + // 'cancel' / 'dismiss' — user backed out, not an error. + return; + } + + // See the token-hydration note above: this only succeeds today if a token already exists in + // SecureStore, which it won't for a fresh social sign-in. Left as the best-available + // client-side step rather than a fabricated token-parsing shortcut. + const token = await getAuthToken(); + const user = token ? await getSession(token) : null; + if (!user) { + setError(t('auth.socialSignInFailed')); + return; + } + + setSignedIn(user); + const pendingDeepLink = user.mustChangePassword ? null : consumePendingDeepLink(); + // A captured deep link is an arbitrary runtime string, not a route this app's typed-routes + // manifest can know statically — `Href` (rather than `as never`) is expo-router's own type + // for exactly this "any valid path string" case. + router.replace((pendingDeepLink ?? '/') as Href); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.unknown')); + } finally { + setLoadingProvider(null); + } + }; + + return ( + <View style={styles.container}> + {error ? <ErrorBanner message={error} /> : null} + <Button + label={t('auth.continueWithGoogle')} + variant="secondary" + loading={loadingProvider === 'google'} + disabled={loadingProvider !== null} + onPress={() => handleSignIn('google')} + /> + <Button + label={t('auth.continueWithFacebook')} + variant="secondary" + loading={loadingProvider === 'facebook'} + disabled={loadingProvider !== null} + onPress={() => handleSignIn('facebook')} + /> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + gap: 8, + }, +}); diff --git a/src/features/auth/use-sign-out.ts b/src/features/auth/use-sign-out.ts new file mode 100644 index 0000000..5d9622a --- /dev/null +++ b/src/features/auth/use-sign-out.ts @@ -0,0 +1,38 @@ +import { Alert } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { signOut as signOutRequest } from '@/api/auth-endpoints'; +import { clearAuthToken, getAuthToken } from '@/api/auth-token'; +import { queryClient } from '@/lib/query-client'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** Single source of truth for signing out, replacing the two near-identical blocks that used to + * live in profile.tsx (no confirmation, not destructive) and settings/index.tsx (confirmed, + * destructive). Both call sites now get the same confirm-then-clear-everything behavior: + * best-effort server-side session revoke, clear the stored token, drop every cached query (it's + * all workspace/session-scoped), clear the selected workspace, then flip the auth store to + * signed-out. */ +export function useSignOut() { + const { t } = useTranslation(); + const setSignedOut = useAuthStore((state) => state.setSignedOut); + const setCurrentWorkspaceId = useWorkspaceStore((state) => state.setCurrentWorkspaceId); + + async function performSignOut() { + const token = await getAuthToken(); + if (token) { + await signOutRequest(token).catch(() => {}); + } + await clearAuthToken(); + queryClient.clear(); + setCurrentWorkspaceId(null); + setSignedOut(); + } + + return function signOut() { + Alert.alert(t('auth.signOutConfirmTitle'), t('auth.signOutConfirmBody'), [ + { text: t('common.cancel'), style: 'cancel' }, + { text: t('auth.signOut'), style: 'destructive', onPress: () => void performSignOut() }, + ]); + }; +} diff --git a/src/features/chat/api/generate-client-id.test.ts b/src/features/chat/api/generate-client-id.test.ts new file mode 100644 index 0000000..a358c73 --- /dev/null +++ b/src/features/chat/api/generate-client-id.test.ts @@ -0,0 +1,15 @@ +import { generateClientId } from '@/features/chat/api/generate-client-id'; + +describe('generateClientId', () => { + it('returns a numeric string', () => { + expect(generateClientId()).toMatch(/^\d+$/); + }); + + it('generates mostly-unique values across repeated calls', () => { + // Timestamp-prefixed + 3-digit random suffix: calls within the same millisecond only draw + // from 1000 possible suffixes, so a tight synchronous loop can occasionally collide — this + // is expected per the source's own collision-tolerance comment, not a bug to chase to zero. + const ids = new Set(Array.from({ length: 50 }, () => generateClientId())); + expect(ids.size).toBeGreaterThan(40); + }); +}); diff --git a/src/features/chat/api/generate-client-id.ts b/src/features/chat/api/generate-client-id.ts new file mode 100644 index 0000000..977c3ec --- /dev/null +++ b/src/features/chat/api/generate-client-id.ts @@ -0,0 +1,15 @@ +/** + * The backend's message-create `clientId` field is typed `zodBigintAsString()` server-side (not a + * free-form UUID) — confirmed by the generated schema also carrying `clientId?: string` on message + * resources themselves. A UUID would fail that validation. This generates a timestamp-prefixed + * pseudo-snowflake: always a valid numeric string, and — since it's only ever compared within one + * client's own pending-sends map for the lifetime of the app process — collision odds are + * negligible without needing real snowflake machinery. + */ +export function generateClientId(): string { + const timestamp = Date.now().toString(); + const random = Math.floor(Math.random() * 1000) + .toString() + .padStart(3, '0'); + return `${timestamp}${random}`; +} diff --git a/src/features/chat/api/send-message-multipart.ts b/src/features/chat/api/send-message-multipart.ts new file mode 100644 index 0000000..5153778 --- /dev/null +++ b/src/features/chat/api/send-message-multipart.ts @@ -0,0 +1,132 @@ +import { getAuthToken } from '@/api/auth-token'; +import { ApiError } from '@/api/errors'; +import { env } from '@/config/env'; + +/** + * Chat message attachments go through the SAME message-create endpoint as text + * (`POST /workspaces/{workspaceId}/conversations/{conversationId}/messages`), but the generated + * TS types are wrong for this case: the backend's real Zod schema types `files` as + * `z.array(z.instanceof(File)).min(1)`, which openapi-typescript cannot express — it collapsed to + * `files?: unknown[]` under `application/json`. The real wire format for a request WITH files is + * `multipart/form-data` (the backend's `createMessage` handler calls `uploadMultipleFiles` on + * genuine binary uploads), not JSON. The typed `apiClient` assumes JSON everywhere, so attachment + * sends bypass it entirely and use a hand-rolled request here (same pattern as api/auth-endpoints.ts + * for other non-JSON-typed routes), building RN's standard FormData-with-file shape + * (`{ uri, type, name }`) which works directly with expo-image-picker/expo-camera result URIs. + * + * UNVERIFIED: this multipart send has not been exercised against a live signed-in session with a + * real conversation — the request shape is inferred from the backend's Zod schema and RN's FormData + * convention, not observed on the wire. Worth a real-device check before shipping. + * + * Built on XMLHttpRequest rather than `fetch` specifically so upload progress is observable: + * `fetch`'s ReadableStream request body isn't supported for uploads in RN's fetch polyfill, and + * even where it is, there's no cross-platform equivalent of `xhr.upload.onprogress`. RN's XHR + * implementation does implement `upload.onprogress` (backed by the native networking layer), so + * this is the only way to surface a progress ratio to the composer/attachment UI. The success/error + * contract (return value, thrown error types) is kept IDENTICAL to the previous fetch-based + * implementation so use-send-message.ts needs no changes beyond passing through `onProgress`. + */ +export interface MultipartAttachment { + uri: string; + mimeType: string; + fileName: string; +} + +export interface SendMultipartMessageParams { + workspaceId: string; + conversationId: string; + text?: string; + clientId: string; + attachments: MultipartAttachment[]; + inboxId?: string | null; + replyToMessageId?: string; + replyToMessageCreatedAt?: string; + /** Called with a 0-1 ratio as the upload progresses. Best-effort — some environments never + * report `lengthComputable`, in which case this simply never fires. */ + onProgress?: (ratio: number) => void; +} + +function parseResponseBody(rawBody: string): unknown { + if (!rawBody) return null; + try { + return JSON.parse(rawBody); + } catch { + // Non-JSON response — leave body null, fall back to statusText below. + return null; + } +} + +/** Builds an `ApiError` from a raw XHR response body, matching the shape + * `normalizeApiError`/`ApiError` expect from the typed `apiClient` path (`{ code, status, + * message }`) — so a 402/401/etc. from an attachment send is classified identically to a + * JSON-body send instead of silently falling through as an unrecognized error type. */ +function toApiError(status: number, body: unknown, fallbackMessage: string): ApiError { + const parsedBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}; + const code = typeof parsedBody.code === 'string' ? parsedBody.code : undefined; + const message = typeof parsedBody.message === 'string' ? parsedBody.message : fallbackMessage; + const data = + parsedBody.data && typeof parsedBody.data === 'object' + ? (parsedBody.data as { reason?: string }) + : undefined; + + return new ApiError({ code, status, message, data }); +} + +export async function sendMultipartMessage(params: SendMultipartMessageParams): Promise<unknown> { + const token = await getAuthToken(); + const url = `${env.apiBaseUrl}/api/workspaces/${params.workspaceId}/conversations/${params.conversationId}/messages`; + + const form = new FormData(); + if (params.text) form.append('text', params.text); + form.append('clientId', params.clientId); + if (params.inboxId) form.append('inboxId', params.inboxId); + if (params.replyToMessageId) form.append('replyToMessageId', params.replyToMessageId); + if (params.replyToMessageCreatedAt) { + form.append('replyToMessageCreatedAt', params.replyToMessageCreatedAt); + } + for (const attachment of params.attachments) { + // React Native's XHR/FormData accepts this `{ uri, type, name }` object in place of a real + // Blob for file parts — the standard RN convention, distinct from web File/Blob uploads. + form.append('files', { + uri: attachment.uri, + type: attachment.mimeType, + name: attachment.fileName, + } as unknown as Blob); + } + + return new Promise<unknown>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + if (xhr.upload && params.onProgress) { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) { + params.onProgress?.(event.loaded / event.total); + } + }; + } + + xhr.onload = () => { + const body = parseResponseBody(xhr.responseText); + const ok = xhr.status >= 200 && xhr.status < 300; + if (!ok) { + reject(toApiError(xhr.status, body, xhr.statusText)); + return; + } + resolve(body); + }; + + xhr.onerror = () => { + reject(toApiError(0, null, 'Network request failed')); + }; + + xhr.onabort = () => { + reject(toApiError(0, null, 'Upload aborted')); + }; + + xhr.send(form); + }); +} diff --git a/src/features/chat/api/use-message-actions.test.tsx b/src/features/chat/api/use-message-actions.test.tsx new file mode 100644 index 0000000..3fe612a --- /dev/null +++ b/src/features/chat/api/use-message-actions.test.tsx @@ -0,0 +1,167 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { apiClient } from '@/api/client'; +import { queryKeys } from '@/api/query-keys'; +import { useDeleteMessage, useSetMessageAttributes } from '@/features/chat/api/use-message-actions'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +jest.mock('@/api/client', () => ({ + apiClient: { + DELETE: jest.fn(), + POST: jest.fn(), + PATCH: jest.fn(), + }, +})); + +const mockedDelete = apiClient.DELETE as jest.Mock; +const mockedPost = apiClient.POST as jest.Mock; + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + mockedDelete.mockReset(); + mockedPost.mockReset(); + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +function fakeMessage(overrides: Partial<Message> = {}): Message { + return { + id: 'msg-1', + clientId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + conversationId: 'conv-1', + workspaceId: 'ws-1', + contactInboxId: '', + text: 'hello', + contentAttributes: null, + messageType: 'outgoing', + contentType: 'text', + senderType: 'user', + sourceId: null, + deletedAt: null, + type: 'message', + parentId: null, + attributes: null, + sendError: null, + attachments: [], + ...overrides, + } as unknown as Message; +} + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +function messagesData(messages: Message[]) { + return { pages: [{ data: messages, nextCursor: null }], pageParams: [undefined] }; +} + +describe('useDeleteMessage', () => { + it('optimistically sets deletedAt and rolls it back on error', async () => { + mockedDelete.mockResolvedValue({ data: undefined, error: { message: 'boom' } }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData([fakeMessage({ deletedAt: null })])); + + const { result } = await renderHook(() => useDeleteMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ messageId: 'msg-1', createdAt: '2026-01-01T00:00:00.000Z' }); + }); + + // Not asserting the transient optimistic (non-null deletedAt) state here: `onMutate` now + // awaits `cancelQueries` before patching, so by the time a `waitFor` poll could observe it, + // the mutation may have already settled and rolled back — a real race, not a test timing + // nuance. The end-to-end contract that matters is covered below: the row must NOT be left + // permanently deleted after an error. + await waitFor(() => expect(result.current.isError).toBe(true)); + + const rolledBack = queryClient.getQueryData<ReturnType<typeof messagesData>>(queryKey); + expect(rolledBack?.pages[0]?.data[0]?.deletedAt).toBeNull(); + }); + + it('keeps deletedAt set and invalidates the list on success', async () => { + mockedDelete.mockResolvedValue({ data: undefined, error: undefined }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + queryClient.setQueryData(queryKey, messagesData([fakeMessage({ deletedAt: null })])); + + const { result } = await renderHook(() => useDeleteMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ messageId: 'msg-1', createdAt: '2026-01-01T00:00:00.000Z' }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey }); + }); +}); + +describe('useSetMessageAttributes', () => { + it('invalidates the messages list once the mutation settles', async () => { + mockedPost.mockResolvedValue({ data: undefined, error: undefined }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + queryClient.setQueryData(queryKey, messagesData([fakeMessage()])); + + const { result } = await renderHook(() => useSetMessageAttributes('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + messageId: 'msg-1', + createdAt: '2026-01-01T00:00:00.000Z', + liked: true, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey }); + }); + + it('invalidates the messages list even when the mutation fails', async () => { + mockedPost.mockResolvedValue({ data: undefined, error: { message: 'boom' } }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + queryClient.setQueryData(queryKey, messagesData([fakeMessage()])); + + const { result } = await renderHook(() => useSetMessageAttributes('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + messageId: 'msg-1', + createdAt: '2026-01-01T00:00:00.000Z', + hidden: true, + }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey }); + }); +}); diff --git a/src/features/chat/api/use-message-actions.ts b/src/features/chat/api/use-message-actions.ts new file mode 100644 index 0000000..fb85689 --- /dev/null +++ b/src/features/chat/api/use-message-actions.ts @@ -0,0 +1,152 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { ListMessagesResponse, Message } from './use-messages-infinite'; + +/** + * Message edit/delete/like/hide, matching the exact request shapes read from + * `messagesAPI.{editMessage,deleteMessage,changeMessageAttributes}AuthenticatedAPI` in + * src/api/generated/schema.ts: + * - edit: PATCH .../messages/{messageId} body `{ createdAt, newText, newAttachmentPath?, + * newAttachmentPublicUrl?, newAttachmentMimeType?, newAttachmentName?, newAttachmentSize?, + * removeAttachment? }` — text-only edit here, attachment-replace is out of scope for this phase. + * - delete: DELETE .../messages/{id} body `{ createdAt }`. + * - like/hide: POST .../messages/{messageId}/attributes body `{ createdAt, liked?, hidden? }` — + * this endpoint IS the like/hide mechanism (no separate dedicated endpoints exist). + * + * All three require `createdAt` in the body alongside the path id — the messages table is + * evidently keyed/partitioned by (id, createdAt), not id alone. + */ + +function patchMessageInCache( + queryClient: ReturnType<typeof useQueryClient>, + queryKey: readonly unknown[], + messageId: string, + patch: Partial<Message>, +) { + queryClient.setQueryData<{ pages: ListMessagesResponse[]; pageParams: unknown[] }>( + queryKey, + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + data: page.data.map((message) => + message.id === messageId ? { ...message, ...patch } : message, + ), + })), + }; + }, + ); +} + +function findCachedMessage( + queryClient: ReturnType<typeof useQueryClient>, + queryKey: readonly unknown[], + messageId: string, +): Message | undefined { + const data = queryClient.getQueryData<{ pages: ListMessagesResponse[] }>(queryKey); + for (const page of data?.pages ?? []) { + const found = page.data.find((message) => message.id === messageId); + if (found) return found; + } + return undefined; +} + +export function useEditMessage(workspaceId: string, conversationId: string) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.ws.messages.list(workspaceId, conversationId); + + return useMutation({ + mutationFn: async (params: { messageId: string; createdAt: string; newText: string }) => { + const { error } = await apiClient.PATCH( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages/{messageId}', + { + params: { path: { workspaceId, conversationId, messageId: params.messageId } }, + body: { createdAt: params.createdAt, newText: params.newText }, + }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (params) => { + await queryClient.cancelQueries({ queryKey }); + const previous = findCachedMessage(queryClient, queryKey, params.messageId); + patchMessageInCache(queryClient, queryKey, params.messageId, { text: params.newText }); + return { previous }; + }, + onError: (_error, params, context) => { + if (!context?.previous) return; + patchMessageInCache(queryClient, queryKey, params.messageId, { text: context.previous.text }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} + +export function useDeleteMessage(workspaceId: string, conversationId: string) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.ws.messages.list(workspaceId, conversationId); + + return useMutation({ + mutationFn: async (params: { messageId: string; createdAt: string }) => { + const { error } = await apiClient.DELETE( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages/{id}', + { + params: { path: { workspaceId, conversationId, id: params.messageId } }, + body: { createdAt: params.createdAt }, + }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (params) => { + await queryClient.cancelQueries({ queryKey }); + const previous = findCachedMessage(queryClient, queryKey, params.messageId); + patchMessageInCache(queryClient, queryKey, params.messageId, { + deletedAt: new Date().toISOString(), + }); + return { previous }; + }, + onError: (_error, params, context) => { + if (!context?.previous) return; + patchMessageInCache(queryClient, queryKey, params.messageId, { + deletedAt: context.previous.deletedAt, + }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} + +/** No optimistic cache patch here (unlike edit/delete above) — `attributes` is a loose JSON-value + * union in the generated schema with no reliable current-liked/hidden shape to read or merge into, + * so this just fires the mutation and lets `onSettled` invalidation reflect the change. */ +export function useSetMessageAttributes(workspaceId: string, conversationId: string) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.ws.messages.list(workspaceId, conversationId); + + return useMutation({ + mutationFn: async (params: { + messageId: string; + createdAt: string; + liked?: boolean; + hidden?: boolean; + }) => { + const { error } = await apiClient.POST( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages/{messageId}/attributes', + { + params: { path: { workspaceId, conversationId, messageId: params.messageId } }, + body: { createdAt: params.createdAt, liked: params.liked, hidden: params.hidden }, + }, + ); + if (error) throw new ApiError(error); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/features/chat/api/use-messages-infinite.ts b/src/features/chat/api/use-messages-infinite.ts new file mode 100644 index 0000000..e329674 --- /dev/null +++ b/src/features/chat/api/use-messages-infinite.ts @@ -0,0 +1,44 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { getNextPageParam } from '@/api/pagination'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; + +/** + * Types derived directly from the generated schema's `operations` map for + * `messagesAPI.listMessagesAuthenticatedAPI` — `GET /workspaces/{workspaceId}/messages` with + * `conversationId` as a QUERY param (not path-nested, unlike message create/edit/delete). Response + * shape matches api/pagination.ts's `CursorPage<T>` (`{ data, nextCursor, prevCursor }`). + */ +type ListMessagesOperation = operations['messagesAPI.listMessagesAuthenticatedAPI']; +export type ListMessagesResponse = + ListMessagesOperation['responses'][200]['content']['application/json']; +export type Message = ListMessagesResponse['data'][number]; + +const PER_PAGE = 30; + +/** Each page returns newest-first; `nextCursor` walks toward older messages. message-list.tsx + * reverses pages back into chronological order before rendering (see its own comment — FlashList + * v2 dropped the `inverted` prop this used to rely on). */ +export function useMessagesInfinite(workspaceId: string | null, conversationId: string | null) { + return useInfiniteQuery({ + queryKey: queryKeys.ws.messages.list(workspaceId ?? '', conversationId ?? ''), + enabled: workspaceId !== null && conversationId !== null, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }): Promise<ListMessagesResponse> => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/messages', { + params: { + path: { workspaceId: workspaceId! }, + query: { conversationId: conversationId!, cursor: pageParam, perPage: PER_PAGE }, + }, + }); + if (error) { + throw new ApiError(error); + } + return data; + }, + getNextPageParam, + }); +} diff --git a/src/features/chat/api/use-send-message.test.tsx b/src/features/chat/api/use-send-message.test.tsx new file mode 100644 index 0000000..bd12912 --- /dev/null +++ b/src/features/chat/api/use-send-message.test.tsx @@ -0,0 +1,299 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { apiClient } from '@/api/client'; +import { queryKeys } from '@/api/query-keys'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { createOptimisticMessage, useSendMessage } from '@/features/chat/api/use-send-message'; + +jest.mock('@/api/client', () => ({ + apiClient: { + POST: jest.fn(), + }, +})); + +const mockedPost = apiClient.POST as jest.Mock; + +const BASE = { + clientId: '1712345678000123', + conversationId: 'conv-1', + workspaceId: 'ws-1', +}; + +describe('createOptimisticMessage', () => { + it('always sets attachments to an array', () => { + // Regression: the optimistic entry previously omitted `attachments` entirely, and + // message-bubble.tsx crashed with "Cannot read property 'length' of undefined" on EVERY send. + const message = createOptimisticMessage({ ...BASE, text: 'hello' }); + + expect(Array.isArray(message.attachments)).toBe(true); + expect(message.attachments).toHaveLength(0); + }); + + it('populates the fields the message bubble renders from', () => { + const message = createOptimisticMessage({ ...BASE, text: 'hello' }); + + // senderType must not be 'contact', or the bubble renders on the inbound (left) side. + expect(message.senderType).toBe('user'); + expect(message.messageType).toBe('outgoing'); + expect(message.contentType).toBe('text'); + expect(message.id).toBe(`optimistic-${BASE.clientId}`); + expect(message.clientId).toBe(BASE.clientId); + expect(message.conversationId).toBe(BASE.conversationId); + expect(typeof message.createdAt).toBe('string'); + }); + + it('maps text to null when omitted and passes it through otherwise', () => { + expect(createOptimisticMessage(BASE).text).toBeNull(); + expect(createOptimisticMessage({ ...BASE, text: 'hi' }).text).toBe('hi'); + }); + + it('renders a picked image as a local-URI image attachment', () => { + const message = createOptimisticMessage({ + ...BASE, + attachments: [ + { uri: 'file:///tmp/photo.jpg', mimeType: 'image/jpeg', fileName: 'photo.jpg' }, + ], + }); + + expect(message.attachments).toHaveLength(1); + const [attachment] = message.attachments; + expect(attachment.fileType).toBe('image'); + // AttachmentView renders the image from `url`; the local file:// URI drives the preview. + expect(attachment.url).toBe('file:///tmp/photo.jpg'); + expect(attachment.mimeType).toBe('image/jpeg'); + expect(attachment.name).toBe('photo.jpg'); + }); + + it('gives each attachment a distinct id', () => { + // AttachmentView keys on `id` — duplicates would collide as React keys. + const message = createOptimisticMessage({ + ...BASE, + attachments: [ + { uri: 'file:///tmp/a.jpg', mimeType: 'image/jpeg', fileName: 'a.jpg' }, + { uri: 'file:///tmp/b.jpg', mimeType: 'image/jpeg', fileName: 'b.jpg' }, + ], + }); + + const ids = message.attachments.map((attachment) => attachment.id); + expect(new Set(ids).size).toBe(2); + }); + + it('treats a non-image mime type as a file, not an image', () => { + // Otherwise AttachmentView's image branch would try to render a PDF as a broken image. + const message = createOptimisticMessage({ + ...BASE, + attachments: [ + { uri: 'file:///tmp/doc.pdf', mimeType: 'application/pdf', fileName: 'doc.pdf' }, + ], + }); + + expect(message.attachments[0].fileType).toBe('file'); + }); + + it('carries a replyTo messageId through as parentId', () => { + const message = createOptimisticMessage({ ...BASE, text: 'hi', parentId: 'parent-msg-1' }); + + expect(message.parentId).toBe('parent-msg-1'); + }); + + it('defaults parentId to null when no reply is set', () => { + const message = createOptimisticMessage({ ...BASE, text: 'hi' }); + + expect(message.parentId).toBeNull(); + }); +}); + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +function messagesData(): { pages: { data: unknown[]; nextCursor: null }[]; pageParams: unknown[] } { + return { pages: [{ data: [], nextCursor: null }], pageParams: [undefined] }; +} + +describe('useSendMessage', () => { + afterEach(() => { + mockedPost.mockReset(); + useChatStore.setState({ + draftsByConversation: {}, + composerModeByConversation: {}, + uploadProgressByClientId: {}, + }); + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } + }); + + it('skips the optimistic insert entirely for a flowId send', async () => { + mockedPost.mockResolvedValue({ data: { id: 'server-1' }, error: undefined }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData()); + + const { result } = await renderHook(() => useSendMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + workspaceId: 'ws-1', + conversationId: 'conv-1', + flowId: 'flow-1', + nodeId: 'node-1', + }); + }); + + // Assert synchronously right after onMutate would have run — no optimistic entry appears at + // any point, not just after settling. + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + const data = queryClient.getQueryData<ReturnType<typeof messagesData>>(queryKey); + expect(data?.pages[0]?.data).toHaveLength(0); + expect(mockedPost).toHaveBeenCalledWith( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages', + expect.objectContaining({ + body: expect.objectContaining({ flowId: 'flow-1', nodeId: 'node-1' }), + }), + ); + }); + + it('populates replyToMessageId/replyToMessageCreatedAt in the request body and parentId on the optimistic entry', async () => { + mockedPost.mockResolvedValue({ data: { id: 'server-1' }, error: undefined }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData()); + + const { result } = await renderHook(() => useSendMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ + workspaceId: 'ws-1', + conversationId: 'conv-1', + text: 'a reply', + replyTo: { messageId: 'parent-1', createdAt: '2026-01-01T00:00:00.000Z' }, + }); + }); + + // `onMutate` awaits `cancelQueries` before patching, so the optimistic entry lands a + // microtask after `mutate()` returns rather than perfectly synchronously — wait for it. + await waitFor(() => { + const data = queryClient.getQueryData<{ + pages: { data: { parentId: string | null }[] }[]; + }>(queryKey); + expect(data?.pages[0]?.data[0]?.parentId).toBe('parent-1'); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockedPost).toHaveBeenCalledWith( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages', + expect.objectContaining({ + body: expect.objectContaining({ + replyToMessageId: 'parent-1', + replyToMessageCreatedAt: '2026-01-01T00:00:00.000Z', + }), + }), + ); + }); + + it('removes the optimistic bubble and restores the draft on a workspaceBlocked rejection', async () => { + mockedPost.mockResolvedValue({ + data: undefined, + error: { code: 'workspaceBlocked', status: 402, message: 'blocked' }, + }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData()); + + const { result } = await renderHook(() => useSendMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ workspaceId: 'ws-1', conversationId: 'conv-1', text: 'hello' }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + const data = queryClient.getQueryData<ReturnType<typeof messagesData>>(queryKey); + expect(data?.pages[0]?.data).toHaveLength(0); + expect(useChatStore.getState().draftsByConversation['conv-1']).toBe('hello'); + }); + + it('keeps a failed bubble (does not remove it) on a non-terminal error', async () => { + mockedPost.mockResolvedValue({ + data: undefined, + error: { code: 'INTERNAL_SERVER_ERROR', status: 500, message: 'boom' }, + }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData()); + + const { result } = await renderHook(() => useSendMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ workspaceId: 'ws-1', conversationId: 'conv-1', text: 'hello' }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + const data = queryClient.getQueryData<{ + pages: { data: { __optimisticStatus?: string }[] }[]; + }>(queryKey); + expect(data?.pages[0]?.data).toHaveLength(1); + expect(data?.pages[0]?.data[0]?.__optimisticStatus).toBe('failed'); + expect(useChatStore.getState().draftsByConversation['conv-1']).toBeUndefined(); + }); + + it('sends the same clientId in the request body as the cached optimistic row', async () => { + // Regression: `mutationFn` and `onMutate` used to each call `generateClientId()` + // independently, so the optimistic bubble and the outgoing request carried different ids — + // producing a duplicate bubble until refetch and an orphaned upload-progress entry. + mockedPost.mockResolvedValue({ data: { id: 'server-1' }, error: undefined }); + const queryClient = createTestQueryClient(); + const queryKey = queryKeys.ws.messages.list('ws-1', 'conv-1'); + queryClient.setQueryData(queryKey, messagesData()); + + const { result } = await renderHook(() => useSendMessage('ws-1', 'conv-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ workspaceId: 'ws-1', conversationId: 'conv-1', text: 'hello' }); + }); + + // `onMutate` awaits `cancelQueries` before patching, so the optimistic entry lands a + // microtask after `mutate()` returns rather than perfectly synchronously — wait for it. + let cachedClientId: string | undefined; + await waitFor(() => { + const data = queryClient.getQueryData<{ pages: { data: { clientId?: string }[] }[] }>( + queryKey, + ); + cachedClientId = data?.pages[0]?.data[0]?.clientId; + expect(cachedClientId).toEqual(expect.any(String)); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockedPost).toHaveBeenCalledWith( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages', + expect.objectContaining({ body: expect.objectContaining({ clientId: cachedClientId }) }), + ); + }); +}); diff --git a/src/features/chat/api/use-send-message.ts b/src/features/chat/api/use-send-message.ts new file mode 100644 index 0000000..303b5af --- /dev/null +++ b/src/features/chat/api/use-send-message.ts @@ -0,0 +1,326 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { generateClientId } from './generate-client-id'; +import type { ListMessagesResponse, Message } from './use-messages-infinite'; +import { type MultipartAttachment, sendMultipartMessage } from './send-message-multipart'; + +interface SendMessageParams { + workspaceId: string; + conversationId: string; + text?: string; + attachments?: MultipartAttachment[]; + /** Pass the same clientId back in to retry a failed send instead of creating a new pending + * bubble — see composer.tsx / message-bubble.tsx retry handler. */ + clientId?: string; + /** Send-a-flow: the server emits N messages for the flow's own steps via realtime rather than + * echoing a single created message, so `onMutate` skips the optimistic bubble entirely when this + * is set (see below). Mutually exclusive with `attachments` — flows don't take file uploads. */ + flowId?: string; + nodeId?: string; + replyTo?: { messageId: string; createdAt: string }; +} + +export const MAX_FILE_SIZE_BYTES = 5 * 1000 * 1000; + +export class AttachmentTooLargeError extends Error { + constructor(fileName: string) { + super(`"${fileName}" is larger than 5MB.`); + this.name = 'AttachmentTooLargeError'; + } +} + +/** Maps a locally-picked file into the SERVER attachment shape so the optimistic bubble can render + * a preview through the normal `AttachmentView` path. `url` points at the local `file://` URI — + * expo-image renders that identically to a remote URL, so no preview-specific rendering code is + * needed. Server-only metadata we can't know yet (`size`, `width`, `height`) is zero/null and is + * not read by any render path; the real values arrive when the send is confirmed. */ +function toOptimisticAttachment( + attachment: MultipartAttachment, + clientId: string, + index: number, + now: string, +): Message['attachments'][number] { + return { + // Unique + stable: AttachmentView uses `id` as its React key, and it must not collide across + // retries of the same message (which reuse the clientId). + id: `optimistic-${clientId}-${index}`, + createdAt: now, + updatedAt: now, + messageCreatedAt: now, + fileType: attachment.mimeType.startsWith('image/') ? 'image' : 'file', + sourceId: null, + mimeType: attachment.mimeType, + width: null, + height: null, + size: 0, + thumbnailPath: null, + originPath: attachment.uri, + name: attachment.fileName, + url: attachment.uri, + }; +} + +/** Builds a STRUCTURALLY COMPLETE `Message` for the optimistic bubble. This entry goes straight + * into the query cache's real `Message[]`, so every field message-bubble.tsx reads must be present + * — notably `attachments`, whose absence previously crashed the chat screen on every send + * (`message.attachments.length` on undefined). Deliberately NOT cast: the return type is checked + * against `Message`, so a future schema change that adds a required field fails the build here + * instead of at runtime on-device. */ +export function createOptimisticMessage(params: { + clientId: string; + conversationId: string; + workspaceId: string; + text?: string; + attachments?: MultipartAttachment[]; + parentId?: string | null; +}): Message { + const now = new Date().toISOString(); + return { + id: `optimistic-${params.clientId}`, + clientId: params.clientId, + createdAt: now, + updatedAt: now, + conversationId: params.conversationId, + workspaceId: params.workspaceId, + // Not known client-side; unread by any render path. + contactInboxId: '', + text: params.text ?? null, + contentAttributes: null, + // Non-'contact' senderType keeps the bubble on the outbound (right) side — see isOutbound() + // in message-bubble.tsx. + messageType: 'outgoing', + contentType: 'text', + senderType: 'user', + sourceId: null, + deletedAt: null, + type: 'message', + parentId: params.parentId ?? null, + attributes: null, + sendError: null, + attachments: (params.attachments ?? []).map((attachment, index) => + toOptimisticAttachment(attachment, params.clientId, index, now), + ), + }; +} + +/** Response body from message-create is typed `unknown` in the generated schema (openapi-typescript + * couldn't resolve the handler's real return type) — the backend almost certainly echoes the + * created message (with server `id` and the `clientId` we sent), but this is not contractually + * guaranteed, so we narrow defensively rather than casting blindly. */ +function parseCreatedMessageId(body: unknown): string | null { + if (body && typeof body === 'object' && 'id' in body && typeof body.id === 'string') { + return body.id; + } + return null; +} + +type MessagesInfiniteData = { pages: ListMessagesResponse[]; pageParams: unknown[] }; + +function removeOptimisticMessageFromData( + old: MessagesInfiniteData | undefined, + clientId: string, +): MessagesInfiniteData | undefined { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + data: page.data.filter( + (message) => !('clientId' in message && message.clientId === clientId), + ), + })), + }; +} + +/** Strips an optimistic entry out of the messages cache entirely — used (unlike the "leave it as + * a failed bubble, tap to retry" default path) for rejections that mean the send fundamentally + * cannot go through in this workspace state (`workspaceBlocked`, `unauthorized`), where leaving a + * perpetually-failed bubble around would be misleading. */ +export function removeOptimisticMessage( + queryClient: ReturnType<typeof useQueryClient>, + queryKey: readonly unknown[], + clientId: string, +): void { + queryClient.setQueryData<MessagesInfiniteData>(queryKey, (old) => + removeOptimisticMessageFromData(old, clientId), + ); +} + +export function useSendMessage(workspaceId: string, conversationId: string) { + const queryClient = useQueryClient(); + const queryKey = queryKeys.ws.messages.list(workspaceId, conversationId); + const setUploadProgress = useChatStore((state) => state.setUploadProgress); + const clearUploadProgress = useChatStore((state) => state.clearUploadProgress); + const setDraft = useChatStore((state) => state.setDraft); + const setJustSentClientId = useChatStore((state) => state.setJustSentClientId); + + const mutation = useMutation({ + mutationFn: async (params: SendMessageParams & { clientId: string }) => { + const { clientId } = params; + const attachments = params.attachments ?? []; + + if (attachments.length > 0) { + const body = await sendMultipartMessage({ + workspaceId, + conversationId, + text: params.text, + clientId, + attachments, + replyToMessageId: params.replyTo?.messageId, + replyToMessageCreatedAt: params.replyTo?.createdAt, + onProgress: (ratio) => setUploadProgress(clientId, ratio), + }); + return { clientId, serverId: parseCreatedMessageId(body) }; + } + + const { data, error } = await apiClient.POST( + '/workspaces/{workspaceId}/conversations/{conversationId}/messages', + { + params: { path: { workspaceId, conversationId } }, + body: { + text: params.text, + clientId, + flowId: params.flowId, + nodeId: params.nodeId, + replyToMessageId: params.replyTo?.messageId, + replyToMessageCreatedAt: params.replyTo?.createdAt, + }, + }, + ); + if (error) { + throw new ApiError(error); + } + return { clientId, serverId: parseCreatedMessageId(data) }; + }, + + onMutate: async (params) => { + const { clientId } = params; + await queryClient.cancelQueries({ queryKey }); + + // Flow sends produce N server-side messages (the flow's own steps), fanned out over + // realtime — there is no single "the message that was sent" to preview optimistically, and + // inserting one would just be a bubble that never reconciles with anything. The composer + // shows a transient "Sending flow…" toast instead (see composer.tsx). + if (params.flowId) { + return { clientId, skippedOptimistic: true as const }; + } + + queryClient.setQueryData<MessagesInfiniteData>(queryKey, (old) => { + if (!old) return old; + const [firstPage, ...restPages] = old.pages; + if (!firstPage) return old; + + const existingIndex = firstPage.data.findIndex( + (message) => 'clientId' in message && message.clientId === clientId, + ); + // `__optimisticStatus` is deliberately off-schema — see optimistic-message.ts for the + // narrowing contract. The base object itself is fully type-checked by the factory. + const optimisticEntry = { + ...createOptimisticMessage({ + clientId, + conversationId, + workspaceId, + text: params.text, + attachments: params.attachments, + parentId: params.replyTo?.messageId ?? null, + }), + __optimisticStatus: 'pending', + } as Message; + + const newData = + existingIndex >= 0 + ? firstPage.data.map((message, index) => + index === existingIndex ? optimisticEntry : message, + ) + : [optimisticEntry, ...firstPage.data]; + + return { ...old, pages: [{ ...firstPage, data: newData }, ...restPages] }; + }); + + return { clientId, skippedOptimistic: false as const }; + }, + + onError: (error, params, context) => { + if (!context) return; + clearUploadProgress(context.clientId); + + // context.skippedOptimistic (flow sends): nothing was inserted, so there's no cache entry + // to touch — but a blocked/unauthorized rejection below still restores the draft. + + const body = error instanceof ApiError ? error.body : null; + const isTerminal = + body?.code === 'workspaceBlocked' || + body?.status === 402 || + body?.code === 'UNAUTHORIZED' || + body?.status === 401; + + if (isTerminal) { + removeOptimisticMessage(queryClient, queryKey, context.clientId); + if (params.text) { + setDraft(conversationId, params.text); + } + return; + } + + if (context.skippedOptimistic) return; + + queryClient.setQueryData<MessagesInfiniteData>(queryKey, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + data: page.data.map((message) => + 'clientId' in message && message.clientId === context.clientId + ? ({ + ...message, + __optimisticStatus: 'failed', + __optimisticError: error instanceof Error ? error.message : 'Failed to send', + } as unknown as Message) + : message, + ), + })), + }; + }); + }, + + onSuccess: (_data, _params, context) => { + if (context) clearUploadProgress(context.clientId); + // The sender's own optimistic bubble is reconciled here by simply refetching — the newly + // created message (with its real server id) will replace the optimistic entry once the list + // refetches, since clientId is embedded on the real message too and message-bubble.tsx keys + // by clientId when present. Realtime handlers handle the same reconciliation for OTHER + // clients viewing this conversation; this path only needs to work for the sender themself. + // Flow sends (no optimistic entry) rely on this same invalidation/refetch (or realtime) to + // surface the flow's resulting messages. + queryClient.invalidateQueries({ queryKey }); + + // Recorded in the shared chat store (not local mutation state) so the chat screen's + // force-scroll-to-own-send logic sees it regardless of which `useSendMessage(...)` instance + // performed the send — the screen's own instance (retry only) and the composer's instance + // are separate mutations. Skipped for flow sends: there's no single bubble to scroll to. + if (context && !context.skippedOptimistic) { + setJustSentClientId(conversationId, context.clientId); + } + }, + }); + + // Normalizes `clientId` once, before React Query hands params to `onMutate` and `mutationFn` + // independently — otherwise each side generates its own id and the optimistic bubble never + // matches the sent request (duplicate bubble, orphaned upload-progress entry, retry-scroll never + // fires). Callers that pass their own `clientId` (retry) are left untouched. + return { + ...mutation, + mutate: (params: SendMessageParams, options?: Parameters<typeof mutation.mutate>[1]) => + mutation.mutate({ ...params, clientId: params.clientId ?? generateClientId() }, options), + mutateAsync: ( + params: SendMessageParams, + options?: Parameters<typeof mutation.mutateAsync>[1], + ) => + mutation.mutateAsync({ ...params, clientId: params.clientId ?? generateClientId() }, options), + }; +} diff --git a/src/features/chat/components/activity-message.tsx b/src/features/chat/components/activity-message.tsx new file mode 100644 index 0000000..d42d693 --- /dev/null +++ b/src/features/chat/components/activity-message.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import { useTheme } from '@/theme/use-theme'; + +interface ActivityMessageProps { + message: Message; +} + +/** `messageType: "activity"` rows (assignment changes, archive, bot toggle, etc. surfaced as + * inline timeline entries) — rendered centered and de-emphasized, not as a bubble. */ +export function ActivityMessage({ message }: ActivityMessageProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + + return ( + <View style={[styles.row, { paddingVertical: spacing.xs }]}> + <Text variant="caption" color="secondary" style={styles.text}> + {message.text ?? t('chat.activityFallback')} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + alignItems: 'center', + }, + text: { + textAlign: 'center', + }, +}); diff --git a/src/features/chat/components/attachment-view.tsx b/src/features/chat/components/attachment-view.tsx new file mode 100644 index 0000000..a8335ca --- /dev/null +++ b/src/features/chat/components/attachment-view.tsx @@ -0,0 +1,194 @@ +import { Image } from 'expo-image'; +import { router } from 'expo-router'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Card } from '@/components/ui/surface'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { useTheme } from '@/theme/use-theme'; + +interface AttachmentViewProps { + attachments: Message['attachments']; + outbound: boolean; + /** clientId of the parent message, for looking up in-flight upload progress. Undefined for a + * confirmed (non-optimistic) message, which never has progress to show. */ + clientId?: string; +} + +const MAX_WIDTH = 240; +const FALLBACK_ASPECT_RATIO = 4 / 3; + +function aspectRatioFor(attachment: Message['attachments'][number]): number { + if (attachment.width && attachment.height && attachment.height > 0) { + return attachment.width / attachment.height; + } + return FALLBACK_ASPECT_RATIO; +} + +function formatBytes(bytes: number): string { + if (bytes <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +function fileIcon(fileType: string): 'videocam' | 'musical-notes' | 'image' | 'document' { + switch (fileType) { + case 'video': + return 'videocam'; + case 'audio': + return 'musical-notes'; + case 'gif': + return 'image'; + default: + return 'document'; + } +} + +function UploadProgressOverlay({ ratio }: { ratio: number }) { + const { colors } = useTheme(); + const percent = Math.round(ratio * 100); + + return ( + <View style={[styles.progressOverlay, { backgroundColor: colors.scrim }]} pointerEvents="none"> + <Text variant="caption" color="inverse" style={styles.progressLabel}> + {`${percent}%`} + </Text> + </View> + ); +} + +function ImageAttachment({ + attachment, + width, + progress, +}: { + attachment: Message['attachments'][number]; + width: number; + progress: number | undefined; +}) { + const { radius } = useTheme(); + const ratio = aspectRatioFor(attachment); + const height = width / ratio; + + return ( + <Pressable + onPress={() => + attachment.url && + router.push({ pathname: '/image-viewer', params: { url: attachment.url } }) + } + style={styles.imageWrap} + > + <Image + source={{ uri: attachment.url ?? undefined }} + style={{ width, height, borderRadius: radius.md }} + contentFit="cover" + /> + {typeof progress === 'number' && progress < 1 ? ( + <UploadProgressOverlay ratio={progress} /> + ) : null} + </Pressable> + ); +} + +/** Renders a message's attachments: a single full-width image, a 2-column grid for multiple + * images, and non-image files as a `Card` row (icon tile + name + size). In-flight upload + * progress (keyed by the parent message's clientId) overlays image attachments as a percentage — + * file-row attachments don't have room for a ring/bar, so progress is implicit there via the + * optimistic pending state on the bubble itself. */ +export function AttachmentView({ attachments, outbound, clientId }: AttachmentViewProps) { + const { colors, spacing } = useTheme(); + const progress = useChatStore((state) => + clientId ? state.uploadProgressByClientId[clientId] : undefined, + ); + + const images = attachments.filter( + (attachment) => attachment.fileType === 'image' && attachment.url, + ); + const files = attachments.filter( + (attachment) => !(attachment.fileType === 'image' && attachment.url), + ); + + const isGrid = images.length > 1; + const imageWidth = isGrid ? (MAX_WIDTH - spacing.xs) / 2 : MAX_WIDTH; + + return ( + <View style={{ gap: spacing.xs }}> + {images.length > 0 ? ( + <View style={[isGrid ? styles.grid : undefined, isGrid ? { gap: spacing.xs } : undefined]}> + {images.map((attachment) => ( + <ImageAttachment + key={attachment.id} + attachment={attachment} + width={imageWidth} + progress={progress} + /> + ))} + </View> + ) : null} + + {files.map((attachment) => { + const textColor = outbound ? colors.bubbleOutText : colors.bubbleInText; + return ( + <Card + key={attachment.id} + radius="sm" + padding="xs" + style={[styles.fileRow, { gap: spacing.xs }]} + > + <View style={[styles.fileIconTile, { backgroundColor: colors.surface0 }]}> + <Icon name={fileIcon(attachment.fileType)} size={20} color={textColor} /> + </View> + <View style={styles.fileMeta}> + <Text variant="caption" numberOfLines={1} style={{ color: textColor }}> + {attachment.name ?? attachment.fileType} + </Text> + {attachment.size ? ( + <Text variant="micro" style={{ color: textColor, opacity: 0.7 }}> + {formatBytes(attachment.size)} + </Text> + ) : null} + </View> + </Card> + ); + })} + </View> + ); +} + +const styles = StyleSheet.create({ + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + imageWrap: { + position: 'relative', + }, + progressOverlay: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + progressLabel: { + fontWeight: '700', + }, + fileRow: { + flexDirection: 'row', + alignItems: 'center', + }, + fileIconTile: { + width: 36, + height: 36, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + fileMeta: { + flex: 1, + gap: 2, + }, +}); diff --git a/src/features/chat/components/chat-header.tsx b/src/features/chat/components/chat-header.tsx new file mode 100644 index 0000000..13e5b85 --- /dev/null +++ b/src/features/chat/components/chat-header.tsx @@ -0,0 +1,168 @@ +import { useQueryClient } from '@tanstack/react-query'; +import type BottomSheet from '@gorhom/bottom-sheet'; +import { router } from 'expo-router'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { LayoutChangeEvent } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Avatar } from '@/components/ui/avatar'; +import { IconButton } from '@/components/ui/icon-button'; +import { Text } from '@/components/ui/text'; +import { ChannelBadge } from '@/features/conversations/components/channel-badge'; +import { useConversationDetail } from '@/features/conversations/api/use-conversation-detail'; +import { useDisableBot, useEnableBot } from '@/features/conversations/api/use-conversation-actions'; +import { isBotActive } from '@/features/conversations/lib/conversation-status'; +import { findConversationInListCache } from '@/features/conversations/lib/find-conversation-in-cache'; +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +import { ConversationActionsSheet } from './conversation-actions-sheet'; + +interface ChatHeaderProps { + workspaceId: string; + conversationId: string; + onBack: () => void; + onLayout?: (event: LayoutChangeEvent) => void; +} + +/** Resolves conversation identity via `useConversationDetail`, seeded with a synchronous + * `findConversationInListCache` placeholder — this is what fixes the "Conversation" fallback bug + * on a deep link straight into a conversation: the header now always has SOME data to render, + * either from the list cache the user navigated from, or (once it resolves) the dedicated detail + * fetch, rather than only ever reading the list cache and falling back to a hardcoded string when + * it's empty (e.g. cold deep link with no list query cached yet). */ +export function ChatHeader({ workspaceId, conversationId, onBack, onLayout }: ChatHeaderProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const queryClient = useQueryClient(); + const actionsSheetRef = useRef<BottomSheet>(null); + + const { data: conversation } = useConversationDetail(workspaceId, conversationId, { + placeholderData: () => findConversationInListCache(queryClient, workspaceId, conversationId), + }); + + const enableBot = useEnableBot(workspaceId); + const disableBot = useDisableBot(workspaceId); + + const contactName = conversation?.contact?.fullName ?? t('conversations.unknownContact'); + const channel = conversation?.contactInboxes?.[0]?.channel; + const assigneeName = conversation?.assignedUser?.name; + const botActive = conversation + ? isBotActive(conversation.botEnabled, conversation.botResumeAt) + : false; + + function handleToggleBot() { + if (!conversation) return; + if (botActive) { + disableBot.mutate([conversationId]); + } else { + enableBot.mutate([conversationId]); + } + } + + const subtitleParts = [ + channel ? t(`conversations.channel${capitalize(channel)}`, { defaultValue: channel }) : null, + assigneeName ? t('conversations.assignedTo', { name: assigneeName }) : null, + ].filter(Boolean); + + return ( + <View + onLayout={onLayout} + style={[ + styles.container, + { + borderBottomColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + gap: spacing.xs, + }, + ]} + > + <IconButton + accessibilityLabel={t('common.back', { defaultValue: 'Back' })} + icon="chevron-back" + variant="ghost" + onPress={onBack} + /> + + <Pressable + style={styles.identity} + accessibilityRole="button" + onPress={() => + router.push({ + pathname: '/(app)/conversations/[conversationId]/contact', + params: { conversationId }, + }) + } + > + <Avatar + uri={conversation?.contact?.avatar} + name={contactName} + size={36} + ring={botActive ? 'bot' : 'none'} + badge={channel ? <ChannelBadge channel={channel} size={12} /> : undefined} + /> + <View style={styles.nameColumn}> + <Text variant="bodyStrong" numberOfLines={1}> + {contactName} + </Text> + {subtitleParts.length > 0 ? ( + <Text variant="caption" color="secondary" numberOfLines={1}> + {subtitleParts.join(' · ')} + </Text> + ) : null} + </View> + </Pressable> + + <IconButton + accessibilityLabel={ + botActive ? t('conversations.disableBot') : t('conversations.enableBot') + } + icon="sparkles" + variant="tonal" + tint={botActive ? colors.bubbleBotAccent : undefined} + style={botActive ? { backgroundColor: withAlpha(colors.bubbleBotAccent, 0.16) } : undefined} + haptic="medium" + onPress={handleToggleBot} + /> + + <IconButton + accessibilityLabel={t('conversations.actions')} + icon="ellipsis-vertical" + variant="ghost" + onPress={() => actionsSheetRef.current?.expand()} + /> + + <ConversationActionsSheet + ref={actionsSheetRef} + workspaceId={workspaceId} + conversationId={conversationId} + conversation={conversation} + onClose={() => actionsSheetRef.current?.close()} + /> + </View> + ); +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: StyleSheet.hairlineWidth, + }, + identity: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flex: 1, + }, + nameColumn: { + gap: 2, + flex: 1, + }, +}); diff --git a/src/features/chat/components/composer-actions-sheet.tsx b/src/features/chat/components/composer-actions-sheet.tsx new file mode 100644 index 0000000..a0f4211 --- /dev/null +++ b/src/features/chat/components/composer-actions-sheet.tsx @@ -0,0 +1,70 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ListItem } from '@/components/ui/list-item'; +import { Sheet, SheetHeader } from '@/components/ui/sheet'; +import { Icon } from '@/components/ui/icon'; +import { useTheme } from '@/theme/use-theme'; + +interface ComposerActionsSheetProps { + onClose: () => void; + onCamera: () => void; + onPhotoLibrary: () => void; + onDocument: () => void; + onSendFlow: () => void; + onSavedReplies: () => void; + /** Omitted entirely (not just disabled) when the sequences feature isn't available yet — see + * the TODO in composer.tsx for why this is optional rather than always-present-but-gated. */ + onSendSequence?: () => void; +} + +/** "[+]" composer action sheet: camera, photo library (multi-select), document, send flow, send + * sequence (feature-gated — see composer.tsx), saved replies. */ +export const ComposerActionsSheet = forwardRef<BottomSheet, ComposerActionsSheetProps>( + function ComposerActionsSheet( + { onClose, onCamera, onPhotoLibrary, onDocument, onSendFlow, onSavedReplies, onSendSequence }, + ref, + ) { + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + <Sheet ref={ref} snapPoints={['50%']} onDismiss={onClose}> + <SheetHeader title={t('chat.attach')} onClose={onClose} /> + <ListItem + title={t('chat.camera')} + leading={<Icon name="camera" size={22} color={colors.textSecondary} />} + onPress={onCamera} + /> + <ListItem + title={t('chat.photoLibrary')} + leading={<Icon name="image" size={22} color={colors.textSecondary} />} + onPress={onPhotoLibrary} + /> + <ListItem + title={t('chat.document')} + leading={<Icon name="document-attach" size={22} color={colors.textSecondary} />} + onPress={onDocument} + /> + <ListItem + title={t('chat.sendFlow')} + leading={<Icon name="git-branch" size={22} color={colors.textSecondary} />} + onPress={onSendFlow} + /> + {onSendSequence ? ( + <ListItem + title={t('chat.sendSequence')} + leading={<Icon name="layers" size={22} color={colors.textSecondary} />} + onPress={onSendSequence} + /> + ) : null} + <ListItem + title={t('chat.savedReplies')} + leading={<Icon name="flash" size={22} color={colors.textSecondary} />} + onPress={onSavedReplies} + /> + </Sheet> + ); + }, +); diff --git a/src/features/chat/components/composer.tsx b/src/features/chat/components/composer.tsx new file mode 100644 index 0000000..f4c85ae --- /dev/null +++ b/src/features/chat/components/composer.tsx @@ -0,0 +1,439 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { router } from 'expo-router'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { + NativeSyntheticEvent, + StyleProp, + TextInputContentSizeChangeEventData, + ViewStyle, +} from 'react-native'; +import { Pressable, StyleSheet, TextInput, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Icon } from '@/components/ui/icon'; +import { IconButton } from '@/components/ui/icon-button'; +import { Text } from '@/components/ui/text'; +import { useToast } from '@/components/ui/toast'; +import { normalizeApiError } from '@/api/errors'; +import { FEATURES } from '@/config/features'; +import { useConversationDetail } from '@/features/conversations/api/use-conversation-detail'; +import type { FlowListItem } from '@/features/flows/api/use-flows'; +import { useEditMessage } from '@/features/chat/api/use-message-actions'; +import { MAX_FILE_SIZE_BYTES, useSendMessage } from '@/features/chat/api/use-send-message'; +import { + partitionBySize, + pickDocuments, + pickFromLibrary, +} from '@/features/chat/lib/pick-attachments'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { SequencePickerSheet } from '@/features/sequences/components/sequence-picker-sheet'; +import { triggerHaptic } from '@/lib/haptics'; +import { useTheme } from '@/theme/use-theme'; + +import { ComposerActionsSheet } from './composer-actions-sheet'; +import { FlowPickerSheet } from './flow-picker-sheet'; +import { QuotaBanner } from './quota-banner'; +import { SavedRepliesSheet } from './saved-replies-sheet'; + +interface ComposerProps { + workspaceId: string; + conversationId: string; + /** Extra style applied to the outermost container — the chat screen uses this to add + * `paddingBottom: insets.bottom` only while the keyboard is closed (open keyboard already + * pushes the composer up against it, so double-padding would leave a visible gap). */ + style?: StyleProp<ViewStyle>; +} + +const MIN_INPUT_HEIGHT = 44; +const MAX_INPUT_HEIGHT = 132; + +/** Text composer: attachment/flow/saved-reply picking via the `[+]` actions sheet, edit/reply + * mode bar, auto-grow input, morphing send button, and the quota-banner gate on 402 + * workspaceBlocked. Failed-send retry lives on the message bubble itself (tap a failed bubble) — + * the composer only starts NEW sends and edits. */ +export function Composer({ workspaceId, conversationId, style }: ComposerProps) { + const { t } = useTranslation(); + const { colors, spacing, radius, scheme } = useTheme(); + const toast = useToast(); + + const draft = useChatStore((state) => state.draftsByConversation[conversationId] ?? ''); + const setDraft = useChatStore((state) => state.setDraft); + const composerMode = useChatStore((state) => state.composerModeByConversation[conversationId]); + const setComposerMode = useChatStore((state) => state.setComposerMode); + + const sendMessage = useSendMessage(workspaceId, conversationId); + const editMessage = useEditMessage(workspaceId, conversationId); + + const actionsSheetRef = useRef<BottomSheet>(null); + const savedRepliesRef = useRef<BottomSheet>(null); + const flowPickerRef = useRef<BottomSheet>(null); + const sequencePickerRef = useRef<BottomSheet>(null); + + // `FEATURES.sendSequence` is off until the backend route ships (see src/config/features.ts) — + // fetching the conversation detail here just to resolve `contactId` would be wasted work while + // the entry is hidden, so this only fires the query once the flag is on. `useConversationDetail` + // itself already no-ops via `enabled` when either id is missing. + const { data: conversationForSequence } = useConversationDetail( + FEATURES.sendSequence ? workspaceId : null, + FEATURES.sendSequence ? conversationId : null, + ); + + const [attachmentError, setAttachmentError] = useState<string | null>(null); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const [quotaBlocked, setQuotaBlocked] = useState<Extract< + ReturnType<typeof normalizeApiError>, + { kind: 'workspaceBlocked' } + > | null>(null); + + const sendButtonScale = useSharedValue(1); + const sendButtonStyle = useAnimatedStyle(() => ({ + transform: [{ scale: sendButtonScale.value }], + })); + + const hasContent = draft.trim().length > 0; + const isEditing = composerMode?.type === 'edit'; + const isReplying = composerMode?.type === 'reply'; + + function handleChangeText(text: string) { + setDraft(conversationId, text); + } + + function handleContentSizeChange( + event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, + ) { + const nextHeight = Math.min( + MAX_INPUT_HEIGHT, + Math.max(MIN_INPUT_HEIGHT, event.nativeEvent.contentSize.height), + ); + setInputHeight(nextHeight); + } + + function dismissComposerMode() { + setComposerMode(conversationId, undefined); + } + + async function handleSend() { + const text = draft.trim(); + if (!text || quotaBlocked) return; + + // reanimated `SharedValue.value` is a mutable UI-thread ref by design (not React state); the + // react-compiler lint rule can't tell it apart from a plain object — see the matching comment + // in pressable-scale.tsx. + // eslint-disable-next-line react-hooks/immutability + sendButtonScale.value = withTiming(0.85, { duration: 80 }, () => { + sendButtonScale.value = withTiming(1, { duration: 120 }); + }); + triggerHaptic('light'); + + if (isEditing && composerMode) { + setDraft(conversationId, ''); + dismissComposerMode(); + editMessage.mutate({ + messageId: composerMode.message.id, + createdAt: composerMode.message.createdAt, + newText: text, + }); + return; + } + + const replyTo = + isReplying && composerMode + ? { + messageId: composerMode.message.id, + createdAt: composerMode.message.createdAt, + } + : undefined; + + setDraft(conversationId, ''); + dismissComposerMode(); + try { + await sendMessage.mutateAsync({ workspaceId, conversationId, text, replyTo }); + } catch (error) { + const normalized = normalizeApiError(error); + if (normalized.kind === 'workspaceBlocked') { + setQuotaBlocked(normalized); + } + // Other failures already surface on the pending bubble itself (see use-send-message.ts + // onError) — no need to re-show them here. + } + } + + async function sendAssets( + assets: { uri: string; mimeType: string; fileName: string; size?: number }[], + ) { + setAttachmentError(null); + const { ok, tooLarge } = partitionBySize(assets, MAX_FILE_SIZE_BYTES); + + if (tooLarge.length > 0) { + setAttachmentError( + t('chat.attachmentsTooLarge', { + count: tooLarge.length, + names: tooLarge.map((asset) => asset.fileName).join(', '), + }), + ); + } + if (ok.length === 0) return; + + try { + await sendMessage.mutateAsync({ + workspaceId, + conversationId, + attachments: ok.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType, + fileName: asset.fileName, + })), + }); + } catch (error) { + const normalized = normalizeApiError(error); + if (normalized.kind === 'workspaceBlocked') { + setQuotaBlocked(normalized); + } + } + } + + function handleCamera() { + actionsSheetRef.current?.close(); + router.push({ pathname: '/camera', params: { conversationId } }); + } + + async function handlePhotoLibrary() { + actionsSheetRef.current?.close(); + const assets = await pickFromLibrary(); + if (assets.length === 0) return; + await sendAssets(assets); + } + + async function handleDocument() { + actionsSheetRef.current?.close(); + const assets = await pickDocuments(); + if (assets.length === 0) return; + await sendAssets(assets); + } + + function handleOpenFlowPicker() { + actionsSheetRef.current?.close(); + flowPickerRef.current?.expand(); + } + + function handleOpenSendSequence() { + actionsSheetRef.current?.close(); + sequencePickerRef.current?.expand(); + } + + async function handleSelectFlow(flow: FlowListItem) { + flowPickerRef.current?.close(); + toast({ message: t('chat.sendingFlow', { name: flow.name }), tone: 'info' }); + try { + await sendMessage.mutateAsync({ workspaceId, conversationId, flowId: flow.id }); + } catch (error) { + const normalized = normalizeApiError(error); + if (normalized.kind === 'workspaceBlocked') { + setQuotaBlocked(normalized); + } else { + toast({ message: t('chat.flowSendFailed'), tone: 'danger' }); + } + } + } + + function handleOpenSavedReplies() { + actionsSheetRef.current?.close(); + savedRepliesRef.current?.expand(); + } + + const modeBarLabel = isEditing + ? t('chat.editingMessage') + : isReplying && composerMode + ? t('chat.replyingTo', { + name: + composerMode.message.senderType === 'contact' + ? t('chat.senderContact') + : t('chat.senderAgent'), + }) + : null; + const modeBarPreview = + composerMode?.message.text ?? (composerMode ? t('chat.repliedAttachment') : null); + + return ( + <View style={style}> + {quotaBlocked ? ( + <QuotaBanner error={quotaBlocked} onDismiss={() => setQuotaBlocked(null)} /> + ) : null} + {attachmentError ? ( + <View style={{ paddingHorizontal: spacing.sm, paddingTop: spacing.xs }}> + <ErrorBanner message={attachmentError} tone="danger" /> + </View> + ) : null} + + {modeBarLabel ? ( + <View + style={[ + styles.modeBar, + { + backgroundColor: colors.surface0, + paddingHorizontal: spacing.ms, + paddingVertical: spacing.xs, + gap: spacing.xxs, + }, + ]} + > + <Icon + name={isEditing ? 'create-outline' : 'arrow-undo-outline'} + size={16} + color={colors.brand} + /> + <View style={styles.modeBarBody}> + <Text variant="caption" style={{ color: colors.brand, fontWeight: '700' }}> + {modeBarLabel} + </Text> + {modeBarPreview ? ( + <Text variant="caption" color="secondary" numberOfLines={1}> + {modeBarPreview} + </Text> + ) : null} + </View> + <IconButton + accessibilityLabel={t('common.close', { defaultValue: 'Close' })} + icon="close" + size="sm" + variant="ghost" + onPress={dismissComposerMode} + /> + </View> + ) : null} + + <View + style={[ + styles.row, + { padding: spacing.sm, gap: spacing.xs, borderTopColor: colors.borderSubtle }, + ]} + > + <IconButton + accessibilityLabel={t('chat.attach')} + icon="add-circle" + size="lg" + variant="ghost" + onPress={() => actionsSheetRef.current?.expand()} + disabled={Boolean(quotaBlocked)} + /> + + <TextInput + value={draft} + onChangeText={handleChangeText} + onContentSizeChange={handleContentSizeChange} + placeholder={t('chat.typeAMessage')} + placeholderTextColor={colors.textSecondary} + keyboardAppearance={scheme} + multiline + editable={!quotaBlocked} + style={[ + styles.input, + { + height: inputHeight, + backgroundColor: colors.surface0, + borderRadius: radius.md, + color: colors.textPrimary, + paddingHorizontal: spacing.sm, + }, + ]} + /> + + <Animated.View style={sendButtonStyle}> + <Pressable + accessibilityRole="button" + accessibilityLabel={t('chat.send')} + onPress={handleSend} + disabled={!hasContent || Boolean(quotaBlocked)} + style={[ + styles.sendButton, + { + backgroundColor: hasContent ? colors.brand : colors.surface0, + borderRadius: radius.full, + }, + (!hasContent || quotaBlocked) && styles.sendButtonDisabled, + ]} + > + <Icon + name={isEditing ? 'checkmark' : 'send'} + size={18} + color={hasContent ? colors.onBrand : colors.textTertiary} + flipRTL={!isEditing} + /> + </Pressable> + </Animated.View> + </View> + + <ComposerActionsSheet + ref={actionsSheetRef} + onClose={() => actionsSheetRef.current?.close()} + onCamera={handleCamera} + onPhotoLibrary={handlePhotoLibrary} + onDocument={handleDocument} + onSendFlow={handleOpenFlowPicker} + onSavedReplies={handleOpenSavedReplies} + // The sequences feature (src/features/sequences/**) now exists — landed in a parallel + // workstream. `onSendSequence` is still omitted entirely (not shown disabled) unless the + // contactId for this conversation has resolved, matching the "omit rather than disable" + // convention this sheet already follows elsewhere. The picker sheet itself handles the + // FEATURES.sendSequence-off "Coming soon" state, so this stays wired even while the flag + // is off — flipping the flag in src/config/features.ts is the only thing left to do once + // the backend route ships. + onSendSequence={conversationForSequence?.contactId ? handleOpenSendSequence : undefined} + /> + + <FlowPickerSheet + ref={flowPickerRef} + workspaceId={workspaceId} + onSelect={handleSelectFlow} + onClose={() => flowPickerRef.current?.close()} + /> + + <SavedRepliesSheet + ref={savedRepliesRef} + workspaceId={workspaceId} + onSelect={(text) => { + setDraft(conversationId, text); + savedRepliesRef.current?.close(); + }} + /> + + {conversationForSequence?.contactId ? ( + <SequencePickerSheet + sheetRef={sequencePickerRef} + workspaceId={workspaceId} + contactId={conversationForSequence.contactId} + /> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'flex-end', + borderTopWidth: StyleSheet.hairlineWidth, + }, + modeBar: { + flexDirection: 'row', + alignItems: 'center', + }, + modeBarBody: { + flex: 1, + }, + input: { + flex: 1, + paddingVertical: 10, + }, + sendButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonDisabled: { + opacity: 0.5, + }, +}); diff --git a/src/features/chat/components/conversation-actions-sheet.tsx b/src/features/chat/components/conversation-actions-sheet.tsx new file mode 100644 index 0000000..82ae265 --- /dev/null +++ b/src/features/chat/components/conversation-actions-sheet.tsx @@ -0,0 +1,146 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { router } from 'expo-router'; +import { forwardRef, useRef } from 'react'; +import { Alert } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { Sheet, SheetHeader } from '@/components/ui/sheet'; +import { AssignmentSheet } from '@/features/conversations/components/assignment-sheet'; +import { + useArchiveConversations, + useAssignConversations, + useFollowConversation, + useMarkConversationUnread, + useUnarchiveConversations, + useUnfollowConversation, +} from '@/features/conversations/api/use-conversation-actions'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; +import { useTheme } from '@/theme/use-theme'; + +interface ConversationActionsSheetProps { + workspaceId: string; + conversationId: string; + conversation: ConversationListItem | undefined; + onClose: () => void; +} + +/** Chat header overflow menu: follow/unfollow, archive/unarchive (with confirm), mark unread, + * assign (reuses the existing assignment-sheet.tsx), view contact. */ +export const ConversationActionsSheet = forwardRef<BottomSheet, ConversationActionsSheetProps>( + function ConversationActionsSheet({ workspaceId, conversationId, conversation, onClose }, ref) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const assignmentSheetRef = useRef<BottomSheet>(null); + + const followConversation = useFollowConversation(workspaceId); + const unfollowConversation = useUnfollowConversation(workspaceId); + const archiveConversations = useArchiveConversations(workspaceId); + const unarchiveConversations = useUnarchiveConversations(workspaceId); + const markUnread = useMarkConversationUnread(workspaceId); + const assignConversations = useAssignConversations(workspaceId); + + const isFollowed = Boolean(conversation?.followed); + const isArchived = Boolean(conversation?.archivedAt); + + function handleToggleFollow() { + if (isFollowed) { + unfollowConversation.mutate(conversationId); + } else { + followConversation.mutate(conversationId); + } + onClose(); + } + + function handleArchive() { + Alert.alert( + isArchived ? t('conversations.unarchive') : t('conversations.archive'), + isArchived ? t('conversations.unarchiveConfirm') : t('conversations.archiveConfirm'), + [ + { text: t('common.cancel', { defaultValue: 'Cancel' }), style: 'cancel' }, + { + text: isArchived ? t('conversations.unarchive') : t('conversations.archive'), + style: 'destructive', + onPress: () => { + if (isArchived) { + unarchiveConversations.mutate([conversationId]); + } else { + archiveConversations.mutate([conversationId]); + } + onClose(); + }, + }, + ], + ); + } + + function handleMarkUnread() { + markUnread.mutate(conversationId); + onClose(); + } + + function handleViewContact() { + onClose(); + router.push({ + pathname: '/(app)/conversations/[conversationId]/contact', + params: { conversationId }, + }); + } + + return ( + <> + <Sheet ref={ref} snapPoints={['45%']} onDismiss={onClose}> + <SheetHeader title={t('conversations.actions')} onClose={onClose} /> + <ListItem + title={isFollowed ? t('conversations.unfollow') : t('conversations.follow')} + leading={ + <Icon + name={isFollowed ? 'star' : 'star-outline'} + size={20} + color={colors.textSecondary} + /> + } + onPress={handleToggleFollow} + /> + <ListItem + title={t('conversations.assign')} + leading={<Icon name="person-add-outline" size={20} color={colors.textSecondary} />} + onPress={() => { + onClose(); + assignmentSheetRef.current?.expand(); + }} + /> + <ListItem + title={t('conversations.markUnread')} + leading={<Icon name="mail-unread-outline" size={20} color={colors.textSecondary} />} + onPress={handleMarkUnread} + /> + <ListItem + title={t('contacts.contactTitle')} + leading={<Icon name="person-outline" size={20} color={colors.textSecondary} />} + onPress={handleViewContact} + /> + <ListItem + title={isArchived ? t('conversations.unarchive') : t('conversations.archive')} + leading={<Icon name="archive-outline" size={20} color={colors.danger} />} + destructive + onPress={handleArchive} + /> + </Sheet> + <AssignmentSheet + sheetRef={assignmentSheetRef} + workspaceId={workspaceId} + onAssign={(userId) => { + if (conversation?.contactId) { + assignConversations.mutate({ + contactIds: [conversation.contactId], + assignedId: userId, + }); + } + }} + /> + </> + ); + }, +); diff --git a/src/features/chat/components/flow-picker-sheet.tsx b/src/features/chat/components/flow-picker-sheet.tsx new file mode 100644 index 0000000..39d6adb --- /dev/null +++ b/src/features/chat/components/flow-picker-sheet.tsx @@ -0,0 +1,46 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import { forwardRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { ListItem } from '@/components/ui/list-item'; +import { Sheet, SheetHeader } from '@/components/ui/sheet'; +import type { FlowListItem } from '@/features/flows/api/use-flows'; +import { useFlows } from '@/features/flows/api/use-flows'; + +interface FlowPickerSheetProps { + workspaceId: string; + onSelect: (flow: FlowListItem) => void; + onClose: () => void; +} + +/** "Send flow" composer entry: lists flows filtered to `active && enableInInbox` — per the plan's + * verified backend note, only flows meeting both are valid to trigger from the inbox composer + * (the endpoint doesn't reject others itself, but the web app applies this same filter when + * building its own picker). */ +export const FlowPickerSheet = forwardRef<BottomSheet, FlowPickerSheetProps>( + function FlowPickerSheet({ workspaceId, onSelect, onClose }, ref) { + const { t } = useTranslation(); + const { data, isLoading } = useFlows(workspaceId); + + const flows = useMemo( + () => (data ?? []).filter((flow) => flow.active && flow.enableInInbox), + [data], + ); + + return ( + <Sheet ref={ref} snapPoints={['60%', '90%']} onDismiss={onClose}> + <SheetHeader title={t('chat.sendFlow')} onClose={onClose} /> + <BottomSheetFlatList + data={flows} + keyExtractor={(flow) => flow.id} + ListEmptyComponent={!isLoading ? <EmptyState title={t('flows.empty')} /> : null} + renderItem={({ item }) => ( + <ListItem title={item.name} showChevron onPress={() => onSelect(item)} /> + )} + /> + </Sheet> + ); + }, +); diff --git a/src/features/chat/components/location-view.tsx b/src/features/chat/components/location-view.tsx new file mode 100644 index 0000000..5863862 --- /dev/null +++ b/src/features/chat/components/location-view.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import { useTheme } from '@/theme/use-theme'; + +interface LocationViewProps { + contentAttributes: Message['contentAttributes']; + outbound: boolean; +} + +/** `contentAttributes` is typed as a loose JSON-value union in the generated schema (no dedicated + * location shape) — this narrows defensively rather than assuming `{ lat, lng }` exist. Renders a + * static "location shared" indicator with coordinates when parseable, since embedding a map view + * is out of scope for this phase. */ +function parseLatLng( + contentAttributes: Message['contentAttributes'], +): { lat: number; lng: number } | null { + if ( + !contentAttributes || + typeof contentAttributes !== 'object' || + Array.isArray(contentAttributes) + ) { + return null; + } + const record = contentAttributes as Record<string, unknown>; + const lat = record.lat ?? record.latitude; + const lng = record.lng ?? record.longitude; + if (typeof lat === 'number' && typeof lng === 'number') { + return { lat, lng }; + } + return null; +} + +export function LocationView({ contentAttributes, outbound }: LocationViewProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const coords = parseLatLng(contentAttributes); + const textColor = outbound ? colors.bubbleOutText : colors.bubbleInText; + + return ( + <View style={[styles.row, { gap: spacing.xs }]}> + <Icon name="location" size={18} color={textColor} /> + <Text variant="caption" style={{ color: textColor }}> + {coords ? `${coords.lat.toFixed(5)}, ${coords.lng.toFixed(5)}` : t('chat.locationShared')} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + }, +}); diff --git a/src/features/chat/components/message-actions-menu.tsx b/src/features/chat/components/message-actions-menu.tsx new file mode 100644 index 0000000..3d15afd --- /dev/null +++ b/src/features/chat/components/message-actions-menu.tsx @@ -0,0 +1,63 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ListItem } from '@/components/ui/list-item'; +import { Sheet } from '@/components/ui/sheet'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +import { getOptimisticStatus } from './optimistic-message'; + +interface MessageActionsMenuProps { + message: Message | null; + onEdit: (message: Message) => void; + onReply: (message: Message) => void; + onDelete: (message: Message) => void; + onLike: (message: Message) => void; + onHide: (message: Message) => void; + /** "Edit & resend" — restores the failed bubble's text to the draft and removes the failed + * entry, distinct from the plain tap-to-retry on the bubble itself (avoids a duplicate-send if + * the user both retries AND resends the restored draft). Only relevant/shown for a failed + * optimistic bubble. */ + onEditAndResend: (message: Message) => void; +} + +/** Long-press action sheet for a message: reply, edit (text messages from us only), delete, like, + * hide, and — for a failed optimistic bubble specifically — "Edit & resend". `attributes.liked`/ + * `attributes.hidden` aren't strongly typed in the generated schema (loose JSON-value union), so + * this always shows both toggle actions rather than trying to read current state from + * `attributes` — the mutation itself is idempotent either way. */ +export const MessageActionsMenu = forwardRef<BottomSheet, MessageActionsMenuProps>( + function MessageActionsMenu( + { message, onEdit, onReply, onDelete, onLike, onHide, onEditAndResend }, + ref, + ) { + const { t } = useTranslation(); + + if (!message) { + return ( + <Sheet ref={ref} snapPoints={['1%']}> + <></> + </Sheet> + ); + } + + const optimistic = getOptimisticStatus(message); + const isFailed = optimistic?.status === 'failed'; + const canEdit = !isFailed && message.senderType !== 'contact' && message.contentType === 'text'; + const canReply = !isFailed && !message.deletedAt; + + return ( + <Sheet ref={ref} snapPoints={['40%']}> + {isFailed ? ( + <ListItem title={t('chat.editAndResend')} onPress={() => onEditAndResend(message)} /> + ) : null} + {canReply ? <ListItem title={t('chat.reply')} onPress={() => onReply(message)} /> : null} + {canEdit ? <ListItem title={t('chat.edit')} onPress={() => onEdit(message)} /> : null} + {!isFailed ? <ListItem title={t('chat.like')} onPress={() => onLike(message)} /> : null} + {!isFailed ? <ListItem title={t('chat.hide')} onPress={() => onHide(message)} /> : null} + <ListItem title={t('chat.delete')} destructive onPress={() => onDelete(message)} /> + </Sheet> + ); + }, +); diff --git a/src/features/chat/components/message-bubble.test.tsx b/src/features/chat/components/message-bubble.test.tsx new file mode 100644 index 0000000..2fdef13 --- /dev/null +++ b/src/features/chat/components/message-bubble.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +import { MessageBubble } from './message-bubble'; + +// expo-router's full module (pulled in transitively via attachment-view.tsx's `router.push` for +// the image viewer) drags in its native-stack navigator, which in turn imports expo-glass-effect's +// native view manager — unavailable under Jest ("requireNativeViewManager is not available"). +// This component only needs the imperative `router.push` call, so a minimal stand-in is enough; +// no navigation actually needs to occur in these tests. jest.mock calls are hoisted above imports +// by babel-jest regardless of source position, so this placement (after the imports, for readable +// import ordering) is functionally identical to placing it first. +jest.mock('expo-router', () => ({ + __esModule: true, + router: { push: jest.fn(), back: jest.fn() }, +})); + +beforeAll(() => { + initI18n(); +}); + +function fakeMessage(overrides: Partial<Message> = {}): Message { + return { + id: 'm-1', + createdAt: '2026-01-01T12:00:00.000Z', + updatedAt: '2026-01-01T12:00:00.000Z', + conversationId: 'conv-1', + workspaceId: 'ws-1', + contactInboxId: '', + text: 'Hello there', + contentAttributes: null, + messageType: 'outgoing', + contentType: 'text', + senderType: 'user', + sourceId: null, + deletedAt: null, + type: 'message', + parentId: null, + attributes: null, + sendError: null, + attachments: [], + ...overrides, + } as unknown as Message; +} + +const NO_OP = () => {}; + +describe('MessageBubble', () => { + it('renders message text', async () => { + await render( + <MessageBubble + message={fakeMessage({ text: 'Hello there' })} + position="single" + showMeta + messagesById={new Map()} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('Hello there')).toBeTruthy(); + }); + + it('renders a deleted-message placeholder instead of the original text', async () => { + await render( + <MessageBubble + message={fakeMessage({ text: 'secret stuff', deletedAt: '2026-01-01T12:05:00.000Z' })} + position="single" + showMeta + messagesById={new Map()} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('Message deleted')).toBeTruthy(); + expect(screen.queryByText('secret stuff')).toBeNull(); + }); + + it('shows the failed-to-send label for a failed optimistic bubble', async () => { + const failedMessage = { + ...fakeMessage({ text: 'oops' }), + __optimisticStatus: 'failed', + __optimisticError: 'Network error', + } as unknown as Message; + + await render( + <MessageBubble + message={failedMessage} + position="single" + showMeta + messagesById={new Map()} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('Network error')).toBeTruthy(); + }); + + it('renders a quoted reply preview when parentId resolves in messagesById', async () => { + const parent = fakeMessage({ id: 'parent-1', text: 'original message' }); + const messagesById = new Map([['parent-1', parent]]); + + await render( + <MessageBubble + message={fakeMessage({ text: 'a reply', parentId: 'parent-1' })} + position="single" + showMeta + messagesById={messagesById} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('original message')).toBeTruthy(); + expect(screen.getByText('a reply')).toBeTruthy(); + }); + + it('renders the "unavailable" fallback when parentId does not resolve', async () => { + await render( + <MessageBubble + message={fakeMessage({ text: 'a reply', parentId: 'missing-parent' })} + position="single" + showMeta + messagesById={new Map()} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('Original message unavailable')).toBeTruthy(); + }); + + it('renders a centered activity chip for messageType "activity" instead of a bubble', async () => { + await render( + <MessageBubble + message={fakeMessage({ messageType: 'activity', text: 'Conversation archived' })} + position="single" + showMeta + messagesById={new Map()} + onLongPress={NO_OP} + onRetry={NO_OP} + />, + ); + + expect(screen.getByText('Conversation archived')).toBeTruthy(); + }); +}); diff --git a/src/features/chat/components/message-bubble.tsx b/src/features/chat/components/message-bubble.tsx new file mode 100644 index 0000000..928800a --- /dev/null +++ b/src/features/chat/components/message-bubble.tsx @@ -0,0 +1,266 @@ +import dayjs from 'dayjs'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import type { GroupPosition } from '@/features/chat/lib/message-groups'; +import { useTheme } from '@/theme/use-theme'; + +import { ActivityMessage } from './activity-message'; +import { AttachmentView } from './attachment-view'; +import { LocationView } from './location-view'; +import { getOptimisticStatus } from './optimistic-message'; +import { RichContentView } from './rich-content-view'; + +type SenderVariant = 'contact' | 'agent' | 'bot' | 'system'; + +interface MessageBubbleProps { + message: Message; + position: GroupPosition; + showMeta: boolean; + /** Lookup for resolving `parentId` into the quoted reply preview — built once by MessageList + * from the full loaded message set, not refetched per bubble. */ + messagesById: Map<string, Message>; + onLongPress?: () => void; + onRetry?: () => void; +} + +/** senderType 'contact' is inbound (left); 'bot' gets its own violet-tinted variant; 'system' is a + * centered activity chip, not a side bubble; everything else ('user'/'api') is a regular outbound + * agent bubble. There's no dedicated `isOutbound` field on the schema, so this is derived from + * senderType instead. */ +function senderVariant(message: Message): SenderVariant { + if (message.senderType === 'contact') return 'contact'; + if (message.senderType === 'bot') return 'bot'; + if (message.senderType === 'system') return 'system'; + return 'agent'; +} + +function formatTime(date: string): string { + return dayjs(date).format('HH:mm'); +} + +function MessageBubbleImpl({ + message, + position, + showMeta, + messagesById, + onLongPress, + onRetry, +}: MessageBubbleProps) { + const { t } = useTranslation(); + const { colors, spacing, radius } = useTheme(); + + if (message.messageType === 'activity') { + return <ActivityMessage message={message} />; + } + + const variant = senderVariant(message); + + if (variant === 'system') { + return <ActivityMessage message={message} />; + } + + const outbound = variant !== 'contact'; + const optimistic = getOptimisticStatus(message); + const isDeleted = Boolean(message.deletedAt); + // Defensive: this component renders whatever is in the query cache, which realtime events also + // write into. A payload missing `attachments` should degrade, not white-screen the chat. + const attachments = message.attachments ?? []; + + const bubbleColor = + variant === 'bot' ? colors.bubbleBot : outbound ? colors.bubbleOut : colors.bubbleIn; + const textColor = + variant === 'bot' + ? colors.bubbleBotText + : outbound + ? colors.bubbleOutText + : colors.bubbleInText; + + // Tails (asymmetric corner radius) only render on the terminal bubble of a group — grouped + // bubbles in the middle stay fully rounded on the "inner" side to visually chain together. + const isGroupEnd = position === 'single' || position === 'last'; + const cornerStyle = outbound + ? { borderBottomEndRadius: isGroupEnd ? radius.xs : radius.bubble } + : { borderBottomStartRadius: isGroupEnd ? radius.xs : radius.bubble }; + + const parent = message.parentId ? messagesById.get(message.parentId) : undefined; + const hasReply = Boolean(message.parentId); + + const timeLabel = formatTime(message.createdAt); + const statusLabel = + optimistic?.status === 'failed' + ? t('chat.status.failed') + : optimistic?.status === 'pending' + ? t('chat.status.pending') + : t('chat.status.sent'); + const senderLabel = + variant === 'bot' + ? t('chat.senderBot') + : variant === 'agent' + ? t('chat.senderAgent') + : t('chat.senderContact'); + const bubbleText = isDeleted ? t('chat.messageDeleted') : (message.text ?? ''); + + const accessibilityLabel = t('chat.bubbleA11yLabel', { + sender: senderLabel, + text: bubbleText, + time: timeLabel, + status: statusLabel, + }); + + return ( + <View + style={[ + styles.row, + outbound ? styles.rowOutbound : styles.rowInbound, + { marginTop: showMeta ? spacing.sm : spacing.xxs }, + ]} + > + {variant === 'bot' && showMeta ? ( + <Text + variant="micro" + style={[styles.botLabel, { color: colors.bubbleBotAccent, marginStart: spacing.xs }]} + > + {t('chat.senderBot')} + </Text> + ) : null} + + <Pressable + accessible + accessibilityLabel={accessibilityLabel} + onLongPress={optimistic || isDeleted ? undefined : onLongPress} + onPress={optimistic?.status === 'failed' ? onRetry : undefined} + style={[ + styles.bubble, + cornerStyle, + { + backgroundColor: bubbleColor, + borderRadius: radius.bubble, + padding: spacing.ms, + opacity: optimistic?.status === 'pending' ? 0.6 : 1, + }, + ]} + > + {hasReply ? ( + <View + style={[ + styles.replyQuote, + { + borderStartColor: textColor, + backgroundColor: colors.scrim, + borderRadius: radius.xs, + padding: spacing.xs, + marginBottom: spacing.xs, + }, + ]} + > + <Text variant="micro" style={{ color: textColor }} numberOfLines={2}> + {parent + ? (parent.text ?? t('chat.repliedAttachment')) + : t('chat.repliedMessageUnavailable')} + </Text> + </View> + ) : null} + + {!isDeleted && attachments.length > 0 ? ( + <AttachmentView + attachments={attachments} + outbound={outbound} + clientId={message.clientId} + /> + ) : null} + + {!isDeleted && message.contentType === 'location' ? ( + <LocationView contentAttributes={message.contentAttributes} outbound={outbound} /> + ) : !isDeleted && message.contentType === 'refLink' ? ( + <RichContentView contentType={message.contentType} outbound={outbound} /> + ) : null} + + {bubbleText ? ( + <Text + style={{ + color: isDeleted ? colors.textTertiary : textColor, + fontStyle: isDeleted ? 'italic' : 'normal', + }} + variant="body" + > + {bubbleText} + </Text> + ) : null} + + {isGroupEnd ? ( + <View style={[styles.metaRow, { gap: spacing.xxs }]}> + {optimistic?.status === 'failed' ? ( + <Icon name="alert-circle" size={12} color={colors.danger} /> + ) : optimistic?.status === 'pending' ? ( + <Icon name="time-outline" size={12} color={textColor} /> + ) : outbound ? ( + <Icon name="checkmark" size={12} color={textColor} /> + ) : null} + <Text variant="micro" numeric style={{ color: textColor, opacity: 0.75 }}> + {timeLabel} + </Text> + </View> + ) : null} + </Pressable> + {optimistic?.status === 'failed' ? ( + <Text variant="caption" color="danger" style={styles.failedLabel}> + {optimistic.errorMessage ?? t('chat.failedToSendTapRetry')} + </Text> + ) : null} + </View> + ); +} + +function messagesEqual(prev: MessageBubbleProps, next: MessageBubbleProps): boolean { + const prevOptimistic = getOptimisticStatus(prev.message); + const nextOptimistic = getOptimisticStatus(next.message); + return ( + prev.message.id === next.message.id && + prev.message.updatedAt === next.message.updatedAt && + prev.message.deletedAt === next.message.deletedAt && + prev.position === next.position && + prev.showMeta === next.showMeta && + prevOptimistic?.status === nextOptimistic?.status && + prev.messagesById === next.messagesById + ); +} + +export const MessageBubble = memo(MessageBubbleImpl, messagesEqual); + +const styles = StyleSheet.create({ + row: { + paddingHorizontal: 12, + }, + // Intentionally left as flex-start/flex-end (not left/right) so RTL auto-mirrors bubble side — + // conventional RTL chat layout (e.g. WhatsApp Arabic still puts outbound bubbles on the left). + rowOutbound: { + alignItems: 'flex-end', + }, + rowInbound: { + alignItems: 'flex-start', + }, + bubble: { + maxWidth: '78%', + gap: 4, + }, + botLabel: { + fontWeight: '700', + marginBottom: 2, + }, + replyQuote: { + borderStartWidth: 2, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-end', + }, + failedLabel: { + marginTop: 2, + }, +}); diff --git a/src/features/chat/components/message-list.tsx b/src/features/chat/components/message-list.tsx new file mode 100644 index 0000000..cec6654 --- /dev/null +++ b/src/features/chat/components/message-list.tsx @@ -0,0 +1,224 @@ +import { FlashList, type FlashListRef } from '@shopify/flash-list'; +import dayjs from 'dayjs'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { StyleSheet } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { DateSeparator } from '@/components/ui/date-separator'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import { computeMessageGroups } from '@/features/chat/lib/message-groups'; + +import { MessageBubble } from './message-bubble'; +import { ScrollToBottomFab } from './scroll-to-bottom-fab'; +import { TypingIndicator } from './typing-indicator'; + +export interface MessageListHandle { + scrollToEnd: () => void; +} + +interface MessageListProps { + messages: Message[]; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + conversationId: string; + /** id of the message most recently sent by the current user, if any — used to force + * auto-scroll-to-end even when the user had scrolled away, since a just-sent message should + * always be visible to its sender. */ + justSentMessageId?: string | null; + onLoadOlder: () => void; + onLongPressMessage: (message: Message) => void; + onRetryMessage: (message: Message) => void; +} + +type ListRow = + | { kind: 'separator'; id: string; date: string } + | { + kind: 'bubble'; + id: string; + message: Message; + position: ReturnType<typeof computeMessageGroups>[number]['position']; + showMeta: boolean; + }; + +const NEAR_BOTTOM_THRESHOLD_PX = 120; + +function buildRows(messages: Message[]): ListRow[] { + const groups = computeMessageGroups(messages); + const rows: ListRow[] = []; + let lastDay: string | null = null; + + messages.forEach((message, index) => { + const day = dayjs(message.createdAt).format('YYYY-MM-DD'); + if (day !== lastDay) { + rows.push({ kind: 'separator', id: `sep-${day}`, date: message.createdAt }); + lastDay = day; + } + const group = groups[index]!; + rows.push({ + kind: 'bubble', + id: message.clientId ?? message.id, + message, + position: group.position, + showMeta: group.showMeta, + }); + }); + + return rows; +} + +/** + * FlashList v2 (installed: 2.0.2) dropped the v1 `inverted` prop in favor of + * `maintainVisibleContentPosition` — so `messages` here is rendered in NATURAL chat order + * (oldest → newest, newest at the bottom), the opposite of the API's own cursor order + * (`nextCursor` walks toward older messages). `flattenMessagePages` in use-messages-infinite.ts + * returns pages in fetch order (newest page first, since page 1 is the most recent messages), so + * the CALLER (the chat screen) is expected to pass `messages` already reversed into chronological + * order — this component memoizes the row-building (grouping + date separators) from whatever + * chronological array it's given, but does not re-reverse on every render itself (that was a + * standing perf bug: the previous version called `[...messages].reverse()` inline in the render + * body, discarding the result every re-render for no reason since `messages` is stable per query + * update). + */ +export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList( + { + messages, + isLoading, + hasNextPage, + isFetchingNextPage, + conversationId, + justSentMessageId, + onLoadOlder, + onLongPressMessage, + onRetryMessage, + }, + ref, +) { + const { t } = useTranslation(); + const listRef = useRef<FlashListRef<ListRow>>(null); + const [isNearBottom, setIsNearBottom] = useState(true); + const [hasNewMessages, setHasNewMessages] = useState(0); + const lastMessageIdRef = useRef<string | null>(null); + // Mirrors `isNearBottom` state into a ref so the new-message effect below can read it as a + // snapshot at the moment a message arrives, without needing it in the effect's dependency array + // (which would re-fire the effect — and risk a duplicate scroll/counter bump — on every scroll). + const isNearBottomRef = useRef(true); + isNearBottomRef.current = isNearBottom; + + const messagesById = useMemo(() => { + const map = new Map<string, Message>(); + for (const message of messages) { + map.set(message.id, message); + if (message.clientId) map.set(message.clientId, message); + } + return map; + }, [messages]); + + const rows = useMemo(() => buildRows(messages), [messages]); + + useImperativeHandle(ref, () => ({ + scrollToEnd: () => { + listRef.current?.scrollToEnd({ animated: true }); + setHasNewMessages(0); + }, + })); + + const lastMessage = messages[messages.length - 1]; + const lastMessageId = lastMessage?.clientId ?? lastMessage?.id ?? null; + + // Auto-scroll when a NEW message arrives (id changed since the previous commit) and either it's + // our own just-sent message or we're already near the bottom; otherwise bump the "new messages" + // counter for the FAB instead of yanking the view. Runs as an effect (not inline during render) + // since scrollToEnd is an imperative side effect on the list ref, which must not fire before the + // ref is attached / the row list has committed. + useEffect(() => { + if (!lastMessageId || lastMessageId === lastMessageIdRef.current) return; + const isFirstRender = lastMessageIdRef.current === null; + const isOwnSend = justSentMessageId != null && lastMessageId === justSentMessageId; + lastMessageIdRef.current = lastMessageId; + + if (isFirstRender) return; + + if (isOwnSend || isNearBottomRef.current) { + listRef.current?.scrollToEnd({ animated: true }); + } else { + setHasNewMessages((count) => count + 1); + } + }, [lastMessageId, justSentMessageId]); + + function handleScroll(event: NativeSyntheticEvent<NativeScrollEvent>) { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const distanceFromEnd = contentSize.height - contentOffset.y - layoutMeasurement.height; + const nearBottom = distanceFromEnd < NEAR_BOTTOM_THRESHOLD_PX; + setIsNearBottom(nearBottom); + if (nearBottom) setHasNewMessages(0); + } + + if (isLoading) { + return ( + <FlashList + data={Array.from({ length: 8 })} + keyExtractor={(_, index) => `skeleton-${index}`} + renderItem={() => <Skeleton height={44} style={styles.skeleton} />} + /> + ); + } + + if (messages.length === 0) { + return <EmptyState icon="chatbubble-ellipses-outline" title={t('chat.noMessages')} />; + } + + return ( + <> + <FlashList + ref={listRef} + data={rows} + keyExtractor={(row) => row.id} + getItemType={(row) => (row.kind === 'separator' ? 'separator' : `bubble-${row.position}`)} + renderItem={({ item }) => + item.kind === 'separator' ? ( + <DateSeparator date={item.date} /> + ) : ( + <MessageBubble + message={item.message} + position={item.position} + showMeta={item.showMeta} + messagesById={messagesById} + onLongPress={() => onLongPressMessage(item.message)} + onRetry={() => onRetryMessage(item.message)} + /> + ) + } + maintainVisibleContentPosition={{ startRenderingFromBottom: true }} + onScroll={handleScroll} + scrollEventThrottle={100} + onStartReachedThreshold={0.4} + onStartReached={() => { + if (hasNextPage && !isFetchingNextPage) { + onLoadOlder(); + } + }} + ListFooterComponent={<TypingIndicator conversationId={conversationId} />} + /> + {!isNearBottom && hasNewMessages > 0 ? ( + <ScrollToBottomFab + newMessageCount={hasNewMessages} + onPress={() => { + listRef.current?.scrollToEnd({ animated: true }); + setHasNewMessages(0); + }} + /> + ) : null} + </> + ); +}); + +const styles = StyleSheet.create({ + skeleton: { + marginHorizontal: 12, + marginVertical: 4, + }, +}); diff --git a/src/features/chat/components/optimistic-message.ts b/src/features/chat/components/optimistic-message.ts new file mode 100644 index 0000000..46dc5fe --- /dev/null +++ b/src/features/chat/components/optimistic-message.ts @@ -0,0 +1,18 @@ +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +/** use-send-message.ts stashes pending/failed status on the cached message entry under these + * `__optimistic*`-prefixed keys (not part of the real `Message` schema type) rather than + * maintaining a separate parallel list — this narrows that back out safely at render time. */ +export interface OptimisticStatus { + status: 'pending' | 'failed'; + errorMessage?: string; +} + +export function getOptimisticStatus(message: Message): OptimisticStatus | null { + const raw = message as unknown as { + __optimisticStatus?: 'pending' | 'failed'; + __optimisticError?: string; + }; + if (!raw.__optimisticStatus) return null; + return { status: raw.__optimisticStatus, errorMessage: raw.__optimisticError }; +} diff --git a/src/features/chat/components/quota-banner.tsx b/src/features/chat/components/quota-banner.tsx new file mode 100644 index 0000000..bcb2115 --- /dev/null +++ b/src/features/chat/components/quota-banner.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next'; + +import { ErrorBanner } from '@/components/ui/error-banner'; +import type { NormalizedApiError } from '@/api/errors'; + +interface QuotaBannerProps { + error: Extract<NormalizedApiError, { kind: 'workspaceBlocked' }>; + onDismiss: () => void; +} + +const COPY_KEY: Record<'mac' | 'trialExpired' | 'unknown', string> = { + mac: 'chat.quotaMac', + trialExpired: 'chat.quotaTrialExpired', + unknown: 'chat.quotaUnknown', +}; + +/** Shown when a send fails with a 402 workspaceBlocked error; disables the composer until + * dismissed (see chat-composer.tsx) — no resolution flow (upgrade CTA) here, that's a later + * billing-integration concern outside this phase's scope. */ +export function QuotaBanner({ error, onDismiss }: QuotaBannerProps) { + const { t } = useTranslation(); + + return ( + <ErrorBanner + message={t(COPY_KEY[error.reason])} + tone="warning" + actionLabel={t('chat.dismiss')} + onAction={onDismiss} + /> + ); +} diff --git a/src/features/chat/components/rich-content-view.tsx b/src/features/chat/components/rich-content-view.tsx new file mode 100644 index 0000000..943991e --- /dev/null +++ b/src/features/chat/components/rich-content-view.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; + +import { Text } from '@/components/ui/text'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +interface RichContentViewProps { + contentType: Message['contentType']; + outbound: boolean; +} + +/** Fallback for `contentType: "refLink"` (and any future content type the app doesn't render a + * dedicated view for) — the schema doesn't expose a structured shape for refLink beyond + * `contentAttributes` (same loose JSON-value union as location), so this renders a plain + * "unsupported content" notice rather than guessing a structured card layout. */ +export function RichContentView({ contentType }: RichContentViewProps) { + const { t } = useTranslation(); + return ( + <Text variant="caption" color="secondary"> + {t('chat.unsupportedContent', { contentType })} + </Text> + ); +} diff --git a/src/features/chat/components/saved-replies-sheet.tsx b/src/features/chat/components/saved-replies-sheet.tsx new file mode 100644 index 0000000..f0d0c1f --- /dev/null +++ b/src/features/chat/components/saved-replies-sheet.tsx @@ -0,0 +1,71 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import { useQuery } from '@tanstack/react-query'; +import { forwardRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { ListItem } from '@/components/ui/list-item'; +import { SearchBar } from '@/components/ui/search-bar'; +import { Sheet } from '@/components/ui/sheet'; +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; + +/** `GET /workspaces/{workspaceId}/saved-replies` — flat list, no cursor pagination (confirmed in + * the generated schema: `{ data: { id, shortcut, text }[] }`), so a plain useQuery is enough. */ +function useSavedReplies(workspaceId: string) { + return useQuery({ + queryKey: queryKeys.ws.savedReplies.list(workspaceId), + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/saved-replies', { + params: { path: { workspaceId } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} + +interface SavedRepliesSheetProps { + workspaceId: string; + onSelect: (text: string) => void; +} + +/** Search-then-edit-before-send per the plan: tapping a reply fills the composer with its text + * (via onSelect) rather than sending immediately, so the agent can adjust it first. */ +export const SavedRepliesSheet = forwardRef<BottomSheet, SavedRepliesSheetProps>( + function SavedRepliesSheet({ workspaceId, onSelect }, ref) { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const { data: replies } = useSavedReplies(workspaceId); + + const filtered = (replies ?? []).filter( + (reply) => + reply.shortcut.toLowerCase().includes(search.toLowerCase()) || + reply.text.toLowerCase().includes(search.toLowerCase()), + ); + + return ( + <Sheet ref={ref} snapPoints={['60%', '90%']}> + <SearchBar + value={search} + onChangeText={setSearch} + placeholder={t('chat.searchSavedReplies')} + /> + <BottomSheetFlatList + data={filtered} + keyExtractor={(reply) => reply.id} + ListEmptyComponent={<EmptyState title={t('chat.noSavedReplies')} />} + renderItem={({ item }) => ( + <ListItem + title={item.shortcut} + subtitle={item.text} + onPress={() => onSelect(item.text)} + /> + )} + /> + </Sheet> + ); + }, +); diff --git a/src/features/chat/components/scroll-to-bottom-fab.tsx b/src/features/chat/components/scroll-to-bottom-fab.tsx new file mode 100644 index 0000000..541c33e --- /dev/null +++ b/src/features/chat/components/scroll-to-bottom-fab.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; +import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated'; + +import { IconButton } from '@/components/ui/icon-button'; +import { useTheme } from '@/theme/use-theme'; + +interface ScrollToBottomFabProps { + newMessageCount: number; + onPress: () => void; +} + +/** Floating action button shown over the message list when the user has scrolled away from the + * bottom and new messages have arrived — tapping scrolls to the end and (via the parent's own + * state reset) clears the badge. */ +export function ScrollToBottomFab({ newMessageCount, onPress }: ScrollToBottomFabProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + + return ( + <Animated.View + entering={FadeInDown} + exiting={FadeOutDown} + style={[styles.wrapper, { bottom: spacing.md, end: spacing.md }]} + > + <IconButton + accessibilityLabel={t('chat.scrollToBottom')} + icon="arrow-down" + variant="filled" + size="lg" + badgeCount={newMessageCount} + onPress={onPress} + haptic="light" + /> + </Animated.View> + ); +} + +const styles = StyleSheet.create({ + wrapper: { + position: 'absolute', + }, +}); diff --git a/src/features/chat/components/typing-indicator.tsx b/src/features/chat/components/typing-indicator.tsx new file mode 100644 index 0000000..cd61b29 --- /dev/null +++ b/src/features/chat/components/typing-indicator.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { useEffect } from 'react'; + +import { Text } from '@/components/ui/text'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { useReducedMotion } from '@/theme/motion'; +import { useTheme } from '@/theme/use-theme'; + +const DOT_COUNT = 3; +const BOUNCE_HEIGHT = -4; +const CYCLE_MS = 900; + +function Dot({ delayMs }: { delayMs: number }) { + const { colors } = useTheme(); + const reducedMotion = useReducedMotion(); + const translateY = useSharedValue(0); + + useEffect(() => { + if (reducedMotion) return undefined; + + translateY.value = withDelay( + delayMs, + withRepeat( + withSequence( + withTiming(BOUNCE_HEIGHT, { duration: CYCLE_MS / 3, easing: Easing.out(Easing.quad) }), + withTiming(0, { duration: CYCLE_MS / 3, easing: Easing.in(Easing.quad) }), + withTiming(0, { duration: CYCLE_MS / 3 }), + ), + -1, + ), + ); + + return () => { + translateY.value = 0; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- shared value ref is stable. + }, [reducedMotion, delayMs]); + + const style = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }] })); + + return ( + <Animated.View + style={[ + styles.dot, + { backgroundColor: colors.textSecondary }, + reducedMotion ? undefined : style, + ]} + /> + ); +} + +interface TypingIndicatorProps { + conversationId: string; +} + +/** Reads `typingByConversation` from the chat store — set by the realtime `typing` event handler. + * Renders nothing when not typing; a `t('chat.typing')`-labeled row with 3 staggered + * bouncing dots otherwise. */ +export function TypingIndicator({ conversationId }: TypingIndicatorProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const isTyping = useChatStore((state) => state.typingByConversation[conversationId] ?? false); + + if (!isTyping) return null; + + return ( + <View + style={[styles.container, { paddingHorizontal: spacing.md, gap: spacing.xs }]} + accessibilityLabel={t('chat.typing')} + accessible + > + <View style={[styles.dotsRow, { gap: spacing.xxs + 1 }]}> + {Array.from({ length: DOT_COUNT }).map((_, index) => ( + <Dot key={index} delayMs={index * (CYCLE_MS / DOT_COUNT)} /> + ))} + </View> + <Text variant="caption" color="secondary"> + {t('chat.typing')} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 4, + }, + dotsRow: { + flexDirection: 'row', + alignItems: 'center', + }, + dot: { + width: 6, + height: 6, + borderRadius: 3, + }, +}); diff --git a/src/features/chat/lib/message-groups.test.ts b/src/features/chat/lib/message-groups.test.ts new file mode 100644 index 0000000..ca86041 --- /dev/null +++ b/src/features/chat/lib/message-groups.test.ts @@ -0,0 +1,93 @@ +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +import { computeMessageGroups } from './message-groups'; + +function fakeMessage(overrides: Partial<Message> = {}): Message { + return { + id: 'm-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + conversationId: 'conv-1', + workspaceId: 'ws-1', + contactInboxId: '', + text: 'hello', + contentAttributes: null, + messageType: 'outgoing', + contentType: 'text', + senderType: 'user', + sourceId: null, + deletedAt: null, + type: 'message', + parentId: null, + attributes: null, + sendError: null, + attachments: [], + ...overrides, + } as unknown as Message; +} + +describe('computeMessageGroups', () => { + it('marks a single isolated message as "single"', () => { + const messages = [fakeMessage({ id: 'a' })]; + + const groups = computeMessageGroups(messages); + + expect(groups).toEqual([{ position: 'single', showMeta: true }]); + }); + + it('groups consecutive same-sender messages within the time window', () => { + const messages = [ + fakeMessage({ id: 'a', senderType: 'user', createdAt: '2026-01-01T00:00:00.000Z' }), + fakeMessage({ id: 'b', senderType: 'user', createdAt: '2026-01-01T00:01:00.000Z' }), + fakeMessage({ id: 'c', senderType: 'user', createdAt: '2026-01-01T00:02:00.000Z' }), + ]; + + const groups = computeMessageGroups(messages); + + expect(groups.map((g) => g.position)).toEqual(['first', 'middle', 'last']); + expect(groups.map((g) => g.showMeta)).toEqual([true, false, false]); + }); + + it('breaks the group when the sender changes', () => { + const messages = [ + fakeMessage({ id: 'a', senderType: 'user', createdAt: '2026-01-01T00:00:00.000Z' }), + fakeMessage({ id: 'b', senderType: 'contact', createdAt: '2026-01-01T00:00:30.000Z' }), + ]; + + const groups = computeMessageGroups(messages); + + expect(groups.map((g) => g.position)).toEqual(['single', 'single']); + }); + + it('breaks the group when messages are more than 5 minutes apart', () => { + const messages = [ + fakeMessage({ id: 'a', senderType: 'user', createdAt: '2026-01-01T00:00:00.000Z' }), + fakeMessage({ id: 'b', senderType: 'user', createdAt: '2026-01-01T00:06:00.000Z' }), + ]; + + const groups = computeMessageGroups(messages); + + expect(groups.map((g) => g.position)).toEqual(['single', 'single']); + }); + + it('never groups activity or system messages with neighbors', () => { + const messages = [ + fakeMessage({ id: 'a', senderType: 'user', createdAt: '2026-01-01T00:00:00.000Z' }), + fakeMessage({ + id: 'b', + senderType: 'system', + messageType: 'activity', + createdAt: '2026-01-01T00:00:10.000Z', + }), + fakeMessage({ id: 'c', senderType: 'user', createdAt: '2026-01-01T00:00:20.000Z' }), + ]; + + const groups = computeMessageGroups(messages); + + expect(groups.map((g) => g.position)).toEqual(['single', 'single', 'single']); + }); + + it('returns an empty array for an empty input', () => { + expect(computeMessageGroups([])).toEqual([]); + }); +}); diff --git a/src/features/chat/lib/message-groups.ts b/src/features/chat/lib/message-groups.ts new file mode 100644 index 0000000..9e47022 --- /dev/null +++ b/src/features/chat/lib/message-groups.ts @@ -0,0 +1,67 @@ +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +export type GroupPosition = 'single' | 'first' | 'middle' | 'last'; + +export interface MessageGroupInfo { + position: GroupPosition; + /** Show the sender/avatar meta (name label, extra top spacing) for this item — true on the + * group's LEADING edge ('first'/'single'). The trailing edge's own meta (timestamp/status) is + * derived separately by the renderer from `position` itself ('last'/'single') rather than a + * second boolean here, since unlike the name label it applies to every group shape uniformly. */ + showMeta: boolean; +} + +const GROUP_WINDOW_MS = 5 * 60 * 1000; + +/** A message never groups with a neighbor if either is a non-bubble render (activity/system) — + * those always stand alone regardless of sender/time proximity. */ +function isGroupable(message: Message): boolean { + return message.messageType !== 'activity' && message.senderType !== 'system'; +} + +function sameSender(a: Message, b: Message): boolean { + // senderType alone isn't a unique identity for 'contact' vs 'user' sends from different agents, + // but the schema doesn't expose a stable per-sender id on every message — senderType is the best + // available signal and matches what message-bubble.tsx already uses to pick bubble side/variant. + return a.senderType === b.senderType; +} + +function withinWindow(a: Message, b: Message): boolean { + const diff = Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return diff <= GROUP_WINDOW_MS; +} + +/** + * Computes consecutive-run grouping for a CHRONOLOGICALLY ORDERED (oldest → newest) message + * array. Messages from the same sender within `GROUP_WINDOW_MS` of the previous message in the + * run are grouped: the first message in a run is 'first', the last is 'last', everything between + * is 'middle', and a run of exactly one message is 'single'. + */ +export function computeMessageGroups(messages: Message[]): MessageGroupInfo[] { + const result: MessageGroupInfo[] = new Array(messages.length); + + let runStart = 0; + for (let i = 0; i < messages.length; i += 1) { + const message = messages[i]!; + const next = messages[i + 1]; + const continuesForward = + next !== undefined && + isGroupable(message) && + isGroupable(next) && + sameSender(message, next) && + withinWindow(message, next); + + if (!continuesForward) { + // Close out the run [runStart, i]. + const runLength = i - runStart + 1; + for (let j = runStart; j <= i; j += 1) { + const position: GroupPosition = + runLength === 1 ? 'single' : j === runStart ? 'first' : j === i ? 'last' : 'middle'; + result[j] = { position, showMeta: position === 'first' || position === 'single' }; + } + runStart = i + 1; + } + } + + return result; +} diff --git a/src/features/chat/lib/pick-attachments.test.ts b/src/features/chat/lib/pick-attachments.test.ts new file mode 100644 index 0000000..e8d11b4 --- /dev/null +++ b/src/features/chat/lib/pick-attachments.test.ts @@ -0,0 +1,67 @@ +import { partitionBySize, type PickedAsset } from './pick-attachments'; + +function fakeAsset(overrides: Partial<PickedAsset> = {}): PickedAsset { + return { + uri: 'file:///tmp/a.jpg', + mimeType: 'image/jpeg', + fileName: 'a.jpg', + ...overrides, + }; +} + +describe('partitionBySize', () => { + const MAX = 5 * 1000 * 1000; + + it('puts assets under the limit into ok', () => { + const asset = fakeAsset({ size: MAX - 1 }); + + const result = partitionBySize([asset], MAX); + + expect(result.ok).toEqual([asset]); + expect(result.tooLarge).toEqual([]); + }); + + it('puts assets over the limit into tooLarge', () => { + const asset = fakeAsset({ size: MAX + 1 }); + + const result = partitionBySize([asset], MAX); + + expect(result.tooLarge).toEqual([asset]); + expect(result.ok).toEqual([]); + }); + + it('treats an asset exactly at the limit as ok (not over)', () => { + const asset = fakeAsset({ size: MAX }); + + const result = partitionBySize([asset], MAX); + + expect(result.ok).toEqual([asset]); + }); + + it('treats assets with unknown size as ok', () => { + const asset = fakeAsset({ size: undefined }); + + const result = partitionBySize([asset], MAX); + + expect(result.ok).toEqual([asset]); + expect(result.tooLarge).toEqual([]); + }); + + it('partitions a mixed batch correctly, preserving order within each bucket', () => { + const small = fakeAsset({ fileName: 'small.jpg', size: 100 }); + const big1 = fakeAsset({ fileName: 'big1.jpg', size: MAX + 1 }); + const unknown = fakeAsset({ fileName: 'unknown.jpg', size: undefined }); + const big2 = fakeAsset({ fileName: 'big2.jpg', size: MAX + 500 }); + + const result = partitionBySize([small, big1, unknown, big2], MAX); + + expect(result.ok).toEqual([small, unknown]); + expect(result.tooLarge).toEqual([big1, big2]); + }); + + it('returns empty buckets for an empty input', () => { + const result = partitionBySize([], MAX); + + expect(result).toEqual({ ok: [], tooLarge: [] }); + }); +}); diff --git a/src/features/chat/lib/pick-attachments.ts b/src/features/chat/lib/pick-attachments.ts new file mode 100644 index 0000000..3f2611c --- /dev/null +++ b/src/features/chat/lib/pick-attachments.ts @@ -0,0 +1,82 @@ +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; + +/** Cap on how many assets a single multi-select library pick can return — an arbitrary but + * generous ceiling to keep one send from trying to upload an unbounded number of files. */ +const MAX_MULTI_SELECT = 10; + +export interface PickedAsset { + uri: string; + mimeType: string; + fileName: string; + /** Bytes, when the picker reports it — undefined means "unknown, don't size-gate this one + * client-side" (the camera capture path is the main case; server-side rejection is the fallback + * per the AGENTS/plan notes on camera.tsx). */ + size?: number; +} + +/** Opens the system photo/video library in multi-select mode. Returns `[]` on cancel — callers + * don't need to branch on `result.canceled` themselves. */ +export async function pickFromLibrary(): Promise<PickedAsset[]> { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images', 'videos'], + allowsMultipleSelection: true, + selectionLimit: MAX_MULTI_SELECT, + quality: 0.9, + }); + + if (result.canceled) return []; + + return result.assets.map((asset, index) => ({ + uri: asset.uri, + mimeType: asset.mimeType ?? (asset.type === 'video' ? 'video/mp4' : 'image/jpeg'), + fileName: + asset.fileName ?? + `attachment-${Date.now()}-${index}.${asset.type === 'video' ? 'mp4' : 'jpg'}`, + size: asset.fileSize ?? undefined, + })); +} + +/** Opens the system document picker in multi-select mode. Returns `[]` on cancel. */ +export async function pickDocuments(): Promise<PickedAsset[]> { + const result = await DocumentPicker.getDocumentAsync({ + multiple: true, + copyToCacheDirectory: true, + }); + + if (result.canceled) return []; + + return result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType ?? 'application/octet-stream', + fileName: asset.name, + size: asset.size ?? undefined, + })); +} + +export interface PartitionResult<T extends PickedAsset> { + ok: T[]; + tooLarge: T[]; +} + +/** Splits a batch of picked assets by a byte-size ceiling. Assets with unknown size (`size === + * undefined`) are treated as OK — there's nothing to gate against client-side, the server is the + * final authority for those. Pure and synchronous so it's fully unit-testable without mocking any + * picker module. */ +export function partitionBySize<T extends PickedAsset>( + assets: T[], + maxBytes: number, +): PartitionResult<T> { + const ok: T[] = []; + const tooLarge: T[] = []; + + for (const asset of assets) { + if (typeof asset.size === 'number' && asset.size > maxBytes) { + tooLarge.push(asset); + } else { + ok.push(asset); + } + } + + return { ok, tooLarge }; +} diff --git a/src/features/chat/stores/use-chat-store.ts b/src/features/chat/stores/use-chat-store.ts new file mode 100644 index 0000000..b98ae79 --- /dev/null +++ b/src/features/chat/stores/use-chat-store.ts @@ -0,0 +1,83 @@ +import { create } from 'zustand'; + +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +export type ComposerMode = { type: 'edit'; message: Message } | { type: 'reply'; message: Message }; + +interface ChatStoreState { + /** conversationId -> is the other side typing (set by realtime; use-realtime-handlers.ts pairs + * every `setTyping(id, true, seconds)` with a typing-timers.ts expiry so this never sticks + * forever if an explicit `typing: false` follow-up is dropped). */ + typingByConversation: Record<string, boolean>; + /** conversationId -> in-progress composer draft text, so switching away and back preserves it. */ + draftsByConversation: Record<string, string>; + /** conversationId -> the composer's current edit/reply mode, if any. Cleared on send/cancel. */ + composerModeByConversation: Record<string, ComposerMode | undefined>; + /** clientId -> upload progress ratio (0-1) for an in-flight multipart send. Set by the + * `onProgress` callback threaded through send-message-multipart.ts, cleared when the mutation + * settles (success or error) — see use-send-message.ts. */ + uploadProgressByClientId: Record<string, number>; + /** The conversationId currently focused/visible on screen, or `null` when no chat screen is + * focused. Set/cleared by the chat screen's `useFocusEffect` (see + * `(app)/conversations/[conversationId]/index.tsx`); read via `useChatStore.getState()` from + * `src/lib/notifications.ts`'s notification handler (outside React) to suppress the in-app + * banner for a push notification about the conversation the user is already looking at. */ + activeConversationId: string | null; + /** conversationId -> the clientId of the most recently successfully-sent message, set once by + * `useSendMessage`'s `onSuccess` (use-send-message.ts). Lives here — not as component state in + * the chat screen — because the screen's own `useSendMessage(...)` instance (used only for the + * retry path) is a SEPARATE mutation from the one `Composer` instantiates for normal sends; a + * `sendMessage.data` read in the screen would never see the composer's own sends. The chat + * screen reads this to force-scroll to a message the user just sent, even if they'd scrolled + * away — see MessageList's `justSentMessageId` prop. */ + justSentClientIdByConversation: Record<string, string | undefined>; + /** `seconds` is the realtime `typing` event's TTL (falls back to a default in the caller when + * 0/undefined) — kept optional so non-realtime callers (tests, other future call sites) aren't + * forced to pass it. Not used by this store itself; use-realtime-handlers.ts reads it back out + * to schedule the matching typing-timers.ts expiry. */ + setTyping: (conversationId: string, typing: boolean, seconds?: number) => void; + setDraft: (conversationId: string, text: string) => void; + setComposerMode: (conversationId: string, mode: ComposerMode | undefined) => void; + setUploadProgress: (clientId: string, ratio: number) => void; + clearUploadProgress: (clientId: string) => void; + setActiveConversationId: (conversationId: string | null) => void; + setJustSentClientId: (conversationId: string, clientId: string) => void; +} + +export const useChatStore = create<ChatStoreState>((set) => ({ + typingByConversation: {}, + draftsByConversation: {}, + composerModeByConversation: {}, + uploadProgressByClientId: {}, + activeConversationId: null, + justSentClientIdByConversation: {}, + setTyping: (conversationId, typing) => + set((state) => ({ + typingByConversation: { ...state.typingByConversation, [conversationId]: typing }, + })), + setActiveConversationId: (conversationId) => set({ activeConversationId: conversationId }), + setDraft: (conversationId, text) => + set((state) => ({ + draftsByConversation: { ...state.draftsByConversation, [conversationId]: text }, + })), + setComposerMode: (conversationId, mode) => + set((state) => ({ + composerModeByConversation: { ...state.composerModeByConversation, [conversationId]: mode }, + })), + setUploadProgress: (clientId, ratio) => + set((state) => ({ + uploadProgressByClientId: { ...state.uploadProgressByClientId, [clientId]: ratio }, + })), + clearUploadProgress: (clientId) => + set((state) => { + const { [clientId]: _removed, ...rest } = state.uploadProgressByClientId; + return { uploadProgressByClientId: rest }; + }), + setJustSentClientId: (conversationId, clientId) => + set((state) => ({ + justSentClientIdByConversation: { + ...state.justSentClientIdByConversation, + [conversationId]: clientId, + }, + })), +})); diff --git a/src/features/contacts/api/use-contact-detail.ts b/src/features/contacts/api/use-contact-detail.ts new file mode 100644 index 0000000..42c285d --- /dev/null +++ b/src/features/contacts/api/use-contact-detail.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; + +/** + * `contactsAPIs.getContactAuthenticatedAPI` (`GET /workspaces/{workspaceId}/contacts/{contactId}`) + * embeds `tags`, `customFields`, `contactNotes`, AND `contactsOnSequences` (each with its full + * nested `sequence` object) in ONE response — verified against the generated schema. No separate + * tags/custom-fields/sequences fetch is needed for the detail view; the panel's Info/Tags/Sequences + * tabs all read off this single query. Sequences data is clean (not "awkward" per the plan's + * fallback note), so the sequences tab is kept rather than dropped. + */ +type GetContactOperation = operations['contactsAPIs.getContactAuthenticatedAPI']; +export type ContactDetail = GetContactOperation['responses'][200]['content']['application/json']; + +export function useContactDetail(workspaceId: string | null, contactId: string | null) { + return useQuery({ + queryKey: queryKeys.ws.contacts.detail(workspaceId ?? '', contactId ?? ''), + enabled: workspaceId !== null && contactId !== null, + queryFn: async (): Promise<ContactDetail> => { + const { data, error } = await apiClient.GET( + '/workspaces/{workspaceId}/contacts/{contactId}', + { params: { path: { workspaceId: workspaceId!, contactId: contactId! } } }, + ); + if (error) throw new ApiError(error); + return data; + }, + }); +} diff --git a/src/features/contacts/api/use-contact-fields.ts b/src/features/contacts/api/use-contact-fields.ts new file mode 100644 index 0000000..31336c5 --- /dev/null +++ b/src/features/contacts/api/use-contact-fields.ts @@ -0,0 +1,74 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; + +/** + * `customFieldsAPI.privateListCustomFieldsAPI` — `GET /workspaces/{workspaceId}/custom-fields`: + * the workspace's full custom-field catalog (id/name/type), separate from a contact's currently + * *set* field values (which come embedded on `useContactDetail`). + */ +export function useWorkspaceCustomFields(workspaceId: string | null) { + return useQuery({ + queryKey: queryKeys.ws.customFields.catalog(workspaceId ?? ''), + enabled: workspaceId !== null, + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/custom-fields', { + params: { path: { workspaceId: workspaceId! } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} + +/** + * `contactsAPIs.addContactFieldAuthenticatedAPI` — + * `POST /workspaces/{workspaceId}/contacts/{contactId}/fields`, body `{ customFieldId, value }`. + * Also used to *update* an already-set field's value — same endpoint upserts. Invalidates the + * contact-detail cache rather than optimistically patching it, since the response body is + * untyped in the generated schema. + */ +export function useSetContactField(workspaceId: string, contactId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: { customFieldId: string; value: string }) => { + const { error } = await apiClient.POST( + '/workspaces/{workspaceId}/contacts/{contactId}/fields', + { + params: { path: { workspaceId, contactId } }, + body: input, + }, + ); + if (error) throw new ApiError(error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.ws.contacts.detail(workspaceId, contactId), + }); + }, + }); +} + +/** `contactsAPIs.deleteContactFieldAuthenticatedAPI` — + * `DELETE /workspaces/{workspaceId}/contacts/{contactId}/fields/{customFieldId}`. */ +export function useDeleteContactField(workspaceId: string, contactId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (customFieldId: string) => { + const { error } = await apiClient.DELETE( + '/workspaces/{workspaceId}/contacts/{contactId}/fields/{customFieldId}', + { params: { path: { workspaceId, contactId, customFieldId } } }, + ); + if (error) throw new ApiError(error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.ws.contacts.detail(workspaceId, contactId), + }); + }, + }); +} diff --git a/src/features/contacts/api/use-contact-tags.test.tsx b/src/features/contacts/api/use-contact-tags.test.tsx new file mode 100644 index 0000000..0e75313 --- /dev/null +++ b/src/features/contacts/api/use-contact-tags.test.tsx @@ -0,0 +1,175 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { apiClient } from '@/api/client'; +import { queryKeys } from '@/api/query-keys'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { useAddContactTag, useRemoveContactTag } from '@/features/contacts/api/use-contact-tags'; + +jest.mock('@/api/client', () => ({ + apiClient: { + POST: jest.fn(), + DELETE: jest.fn(), + }, +})); + +const mockedPost = apiClient.POST as jest.Mock; +const mockedDelete = apiClient.DELETE as jest.Mock; + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + mockedPost.mockReset(); + mockedDelete.mockReset(); + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +function fakeContact(overrides: Partial<ContactDetail> = {}): ContactDetail { + return { + id: 'contact-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + avatar: null, + phoneNumber: null, + email: null, + fullName: 'Jane Doe', + workspaceId: 'ws-1', + tags: [], + customFields: [], + contactNotes: [], + contactsOnSequences: [], + ...overrides, + } as unknown as ContactDetail; +} + +function fakeTag(id: string, name: string) { + return { + id, + name, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + deletedAt: null, + folderId: null, + workspaceId: 'ws-1', + } as unknown as ContactDetail['tags'][number]; +} + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +describe('useAddContactTag', () => { + it('optimistically pushes the full tag object into the cached contact-detail tags array', async () => { + mockedPost.mockResolvedValue({ error: undefined }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.contacts.detail('ws-1', 'contact-1'); + queryClient.setQueryData(detailKey, fakeContact()); + + const { result } = await renderHook(() => useAddContactTag('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + const tag = fakeTag('tag-1', 'VIP'); + act(() => { + result.current.mutate(tag); + }); + + await waitFor(() => { + const detail = queryClient.getQueryData<ContactDetail>(detailKey); + expect(detail?.tags).toEqual([tag]); + }); + }); + + it('rolls back the optimistic tag on error', async () => { + mockedPost.mockResolvedValue({ error: { message: 'boom' } }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.contacts.detail('ws-1', 'contact-1'); + queryClient.setQueryData(detailKey, fakeContact()); + + const { result } = await renderHook(() => useAddContactTag('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate(fakeTag('tag-1', 'VIP')); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData<ContactDetail>(detailKey)?.tags).toEqual([]); + }); + + it('invalidates the detail query on settle to reconcile with server truth', async () => { + mockedPost.mockResolvedValue({ error: undefined }); + const queryClient = createTestQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const detailKey = queryKeys.ws.contacts.detail('ws-1', 'contact-1'); + queryClient.setQueryData(detailKey, fakeContact()); + + const { result } = await renderHook(() => useAddContactTag('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate(fakeTag('tag-1', 'VIP')); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: detailKey }); + }); +}); + +describe('useRemoveContactTag', () => { + it('optimistically removes the tag from the cached contact-detail tags array', async () => { + mockedDelete.mockResolvedValue({ error: undefined }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.contacts.detail('ws-1', 'contact-1'); + const tag = fakeTag('tag-1', 'VIP'); + queryClient.setQueryData(detailKey, fakeContact({ tags: [tag] })); + + const { result } = await renderHook(() => useRemoveContactTag('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate('tag-1'); + }); + + await waitFor(() => { + const detail = queryClient.getQueryData<ContactDetail>(detailKey); + expect(detail?.tags).toEqual([]); + }); + }); + + it('rolls back the optimistic removal on error', async () => { + mockedDelete.mockResolvedValue({ error: { message: 'boom' } }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.contacts.detail('ws-1', 'contact-1'); + const tag = fakeTag('tag-1', 'VIP'); + queryClient.setQueryData(detailKey, fakeContact({ tags: [tag] })); + + const { result } = await renderHook(() => useRemoveContactTag('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate('tag-1'); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData<ContactDetail>(detailKey)?.tags).toEqual([tag]); + }); +}); diff --git a/src/features/contacts/api/use-contact-tags.ts b/src/features/contacts/api/use-contact-tags.ts new file mode 100644 index 0000000..de3d451 --- /dev/null +++ b/src/features/contacts/api/use-contact-tags.ts @@ -0,0 +1,110 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; + +export type WorkspaceTag = ContactDetail['tags'][number]; + +/** + * Available-tags picker source: `tagsAPI.privateListWorkspaceTagsAPI` + * (`GET /workspaces/{workspaceId}/tags`) — the workspace's full tag catalog, separate from a + * contact's assigned tags (which come embedded in useContactDetail's `tags` field). + */ +export function useWorkspaceTags(workspaceId: string | null) { + return useQuery({ + queryKey: queryKeys.ws.tags.catalog(workspaceId ?? ''), + enabled: workspaceId !== null, + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/tags', { + params: { path: { workspaceId: workspaceId! } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} + +/** + * `contactsAPIs.addContactTagAuthenticatedAPI` — `POST /workspaces/{workspaceId}/contacts/tags`, + * body `{ ids: string[], tags: string[] }`: batch by CONTACT id (mirrors the assign-by-contact + * pattern used elsewhere), `tags` is an array of tag ids. + * + * Optimistic: takes the full tag object (not just an id) so `onMutate` can push it straight into + * the cached contact-detail's `tags` array and the UI shows the new chip immediately, instead of + * waiting on a refetch. Rolls back via the pre-mutation snapshot on error; `onSettled` always + * invalidates to reconcile with server truth (the response body is untyped in the generated + * schema, so there's nothing authoritative to merge in on success). + */ +export function useAddContactTag(workspaceId: string, contactId: string) { + const queryClient = useQueryClient(); + const detailKey = queryKeys.ws.contacts.detail(workspaceId, contactId); + + return useMutation({ + mutationFn: async (tag: WorkspaceTag) => { + const { error } = await apiClient.POST('/workspaces/{workspaceId}/contacts/tags', { + params: { path: { workspaceId } }, + body: { ids: [contactId], tags: [tag.id] }, + }); + if (error) throw new ApiError(error); + }, + onMutate: async (tag: WorkspaceTag) => { + await queryClient.cancelQueries({ queryKey: detailKey }); + const previous = queryClient.getQueryData<ContactDetail>(detailKey); + if (previous && !previous.tags.some((existing) => existing.id === tag.id)) { + queryClient.setQueryData<ContactDetail>(detailKey, { + ...previous, + tags: [...previous.tags, tag], + }); + } + return { previous }; + }, + onError: (_err, _tag, context) => { + if (context?.previous) { + queryClient.setQueryData(detailKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: detailKey }); + }, + }); +} + +/** `contactsAPIs.removeContactTagAuthenticatedAPI` — + * `DELETE /workspaces/{workspaceId}/contacts/{contactId}/tags/{tagId}`, single contact/tag pair. + * Optimistic: removes the tag from the cached contact-detail's `tags` array immediately, rolls + * back on error, reconciles with a settle-time invalidation. */ +export function useRemoveContactTag(workspaceId: string, contactId: string) { + const queryClient = useQueryClient(); + const detailKey = queryKeys.ws.contacts.detail(workspaceId, contactId); + + return useMutation({ + mutationFn: async (tagId: string) => { + const { error } = await apiClient.DELETE( + '/workspaces/{workspaceId}/contacts/{contactId}/tags/{tagId}', + { params: { path: { workspaceId, contactId, tagId } } }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (tagId: string) => { + await queryClient.cancelQueries({ queryKey: detailKey }); + const previous = queryClient.getQueryData<ContactDetail>(detailKey); + if (previous) { + queryClient.setQueryData<ContactDetail>(detailKey, { + ...previous, + tags: previous.tags.filter((tag) => tag.id !== tagId), + }); + } + return { previous }; + }, + onError: (_err, _tagId, context) => { + if (context?.previous) { + queryClient.setQueryData(detailKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: detailKey }); + }, + }); +} diff --git a/src/features/contacts/api/use-contacts-infinite.ts b/src/features/contacts/api/use-contacts-infinite.ts new file mode 100644 index 0000000..d32ae07 --- /dev/null +++ b/src/features/contacts/api/use-contacts-infinite.ts @@ -0,0 +1,53 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; + +/** + * `contactsAPIs.listContactsAuthenticatedAPI` (`GET /workspaces/{workspaceId}/contacts`) uses + * OFFSET pagination (`page`/`perPage`/`pageCount`/`totalCount`), NOT the cursor shape + * (`{ data, nextCursor }`) every other list endpoint in this app uses — verified directly against + * the generated schema. `api/pagination.ts`'s CursorPage helpers don't apply here; this hook + * drives `useInfiniteQuery` with a page-number `pageParam` instead. + * + * KNOWN LIMITATION: unlike conversations-list, this endpoint has no first-class + * `assignedId` field — the only way to filter contacts by assignment is the generic `contactFilter` + * condition-builder DSL, which this app deliberately doesn't expose (see filter-sheet.tsx's own + * note on the conversations side). That means `onlyAssignedContacts` (from `usePermissions`) + * can be enforced on the conversations tab but NOT on this contacts directory — a restricted + * member can browse the full contact list here even though their conversation view is scoped to + * their own assignments. Closing this gap would require building the `contactFilter` DSL query, + * which is out of scope for this pass; flagging it explicitly rather than silently leaving it. + */ +type ListContactsOperation = operations['contactsAPIs.listContactsAuthenticatedAPI']; +export type ListContactsResponse = + ListContactsOperation['responses'][200]['content']['application/json']; +export type ContactListItem = ListContactsResponse['data'][number]; + +const PER_PAGE = 30; + +export function useContactsInfinite(workspaceId: string | null, keyword: string) { + return useInfiniteQuery({ + queryKey: queryKeys.ws.contacts.list(workspaceId ?? '', { keyword }), + enabled: workspaceId !== null, + initialPageParam: 1, + queryFn: async ({ pageParam }): Promise<ListContactsResponse> => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/contacts', { + params: { + path: { workspaceId: workspaceId! }, + query: { page: pageParam, perPage: PER_PAGE, keyword: keyword || undefined }, + }, + }); + if (error) throw new ApiError(error); + return data; + }, + getNextPageParam: (lastPage, allPages) => + allPages.length < lastPage.pageCount ? allPages.length + 1 : undefined, + }); +} + +export function flattenContactPages(pages: ListContactsResponse[] | undefined): ContactListItem[] { + return pages?.flatMap((page) => page.data) ?? []; +} diff --git a/src/features/contacts/contact-header.tsx b/src/features/contacts/contact-header.tsx new file mode 100644 index 0000000..84de3f8 --- /dev/null +++ b/src/features/contacts/contact-header.tsx @@ -0,0 +1,138 @@ +import { router } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Avatar } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { ChannelBadge } from '@/features/conversations/components/channel-badge'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { useTheme } from '@/theme/use-theme'; + +const AVATAR_SIZE = 88; + +export type ContactPanelOrigin = 'conversation' | 'directory'; + +interface ContactHeaderProps { + contact: ContactDetail; + /** Which route opened this panel — 'conversation' when reached from a conversation's own + * contact tab (the conversation is already the one "Message" would navigate to, so it goes + * back instead of forward), 'directory' when reached from the standalone contacts tab (in + * which case "Message" pushes to the contact's conversation). */ + origin: ContactPanelOrigin; + /** The contact's channel identifiers (e.g. `['whatsapp', 'email']`), for the channel chip row. + * `useContactDetail`'s response doesn't embed `contactInboxes` (only the contacts-LIST + * endpoints do — see find-contact-in-cache.ts) so the caller sources this from whichever list + * cache is available; omitted/empty renders no chips. */ + channels?: string[]; + /** The contact's active conversation id, if any — sourced the same way as `channels` (not + * present on the contact-detail response). Drives the "Message" CTA; the button is disabled + * when this is null/undefined. */ + conversationId?: string | null; + onAssign?: () => void; +} + +/** + * No block/unblock action here: `contactsAPIs.blockContactAuthenticatedAPI` doesn't exist in the + * generated schema — only `blockContactWorkspaceTokenAPI`/`unblockContactWorkspaceTokenAPI` do, + * which require workspace-token auth, not the bearer/session auth this app uses throughout. The + * header shows the current blocked state (read from `blockedAt`, kept live via the realtime + * contactBlocked/contactUnblocked appliers) but can't toggle it from mobile. + */ +export function ContactHeader({ + contact, + origin, + channels = [], + conversationId = null, + onAssign, +}: ContactHeaderProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const name = contact.fullName ?? contact.email ?? contact.phoneNumber ?? t('contacts.unknown'); + const uniqueChannels = Array.from(new Set(channels)); + + const hasConversation = Boolean(conversationId); + + function handleMessage() { + if (!conversationId) return; + if (origin === 'conversation') { + router.back(); + return; + } + router.push(`/(app)/conversations/${conversationId}`); + } + + return ( + <View style={[styles.container, { padding: spacing.lg, gap: spacing.sm }]}> + <Avatar uri={contact.avatar} name={name} size={AVATAR_SIZE} /> + <Text variant="heading" style={styles.centered}> + {name} + </Text> + + {uniqueChannels.length > 0 || contact.blockedAt ? ( + <View style={[styles.chipRow, { gap: spacing.xs }]}> + {uniqueChannels.map((channel) => ( + <ChannelBadge key={channel} channel={channel} size={16} /> + ))} + {contact.blockedAt ? <Badge tone="danger">{t('contacts.blocked')}</Badge> : null} + </View> + ) : null} + + {contact.email ? ( + <Text variant="body" color="secondary"> + {contact.email} + </Text> + ) : null} + {contact.phoneNumber ? ( + <Text variant="body" color="secondary"> + {contact.phoneNumber} + </Text> + ) : null} + + <View style={[styles.ctaRow, { gap: spacing.sm, marginTop: spacing.xs }]}> + <Button + label={t('contacts.message')} + variant="tonal" + icon="chatbubble-outline" + disabled={!hasConversation} + onPress={handleMessage} + /> + {onAssign ? ( + <Button + label={t('contacts.assign')} + variant="tonal" + icon="person-add-outline" + onPress={onAssign} + /> + ) : null} + </View> + + <View style={[styles.divider, { backgroundColor: colors.borderSubtle }]} /> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + centered: { + textAlign: 'center', + }, + chipRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + justifyContent: 'center', + }, + ctaRow: { + flexDirection: 'row', + justifyContent: 'center', + }, + divider: { + height: StyleSheet.hairlineWidth, + width: '100%', + marginTop: 8, + }, +}); diff --git a/src/features/contacts/contact-panel.tsx b/src/features/contacts/contact-panel.tsx new file mode 100644 index 0000000..39220b2 --- /dev/null +++ b/src/features/contacts/contact-panel.tsx @@ -0,0 +1,168 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshControl, ScrollView, View } from 'react-native'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { ErrorBanner } from '@/components/ui/error-banner'; +import { Screen } from '@/components/ui/screen'; +import { SegmentedTabs } from '@/components/ui/segmented-tabs'; +import { Skeleton } from '@/components/ui/skeleton'; +import { WorkspaceBlockedGate } from '@/components/workspace-blocked-gate'; +import { normalizeApiError } from '@/api/errors'; +import { describeApiError } from '@/lib/describe-api-error'; +import { AssignmentSheet } from '@/features/conversations/components/assignment-sheet'; +import { useAssignConversations } from '@/features/conversations/api/use-conversation-actions'; +import { useContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { ContactHeader, type ContactPanelOrigin } from '@/features/contacts/contact-header'; +import { findContactInListCache } from '@/features/contacts/lib/find-contact-in-cache'; +import { InfoTab } from '@/features/contacts/info-tab'; +import { NotesTab } from '@/features/contacts/notes-tab'; +import { SequencesTab } from '@/features/contacts/sequences-tab'; +import { TagsTab } from '@/features/contacts/tags-tab'; +import { useTheme } from '@/theme/use-theme'; + +type Tab = 'info' | 'tags' | 'notes' | 'sequences'; + +interface ContactPanelProps { + workspaceId: string | null; + contactId: string | null; + /** Which route opened this panel — see contact-header.tsx's `ContactHeaderProps.origin` doc. + * Defaults to 'directory' (the more common entry point, and the safer default for the + * "Message" CTA's push-forward behavior). */ + origin?: ContactPanelOrigin; + /** + * Authoritative channel/conversation data the caller already has on hand (e.g. the + * conversation-nested route already fetched its own `conversation` object, which embeds + * `contactInboxes` directly — no need to fall back to a cache scan). When omitted, falls back + * to `findContactInListCache` (best-effort scan of the contacts-list cache — see that file's + * doc comment for why `useContactDetail` alone can't supply this). + */ + channels?: string[]; + conversationId?: string | null; +} + +/** Shared by both contact-detail routes ((app)/contacts/[contactId] and + * (app)/conversations/[conversationId]/contact) — same tabs, same data source. + * + * All four tabs render inline into ONE scroll container owned by this panel (with pull-to- + * refresh), rather than each tab owning its own ScrollView/FlatList — fixes the audit finding + * about inconsistent scroll behavior across Info/Tags/Sequences. */ +export function ContactPanel({ + workspaceId, + contactId, + origin = 'directory', + channels: channelsOverride, + conversationId: conversationIdOverride, +}: ContactPanelProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const queryClient = useQueryClient(); + const [tab, setTab] = useState<Tab>('info'); + const assignmentSheetRef = useRef<BottomSheet>(null); + const { + data: contact, + isLoading, + isRefetching, + error, + refetch, + } = useContactDetail(workspaceId, contactId); + const assignMutation = useAssignConversations(workspaceId ?? ''); + + const cachedListEntry = + !channelsOverride && workspaceId && contactId + ? findContactInListCache(queryClient, workspaceId, contactId) + : undefined; + const channels = + channelsOverride ?? cachedListEntry?.contactInboxes?.map((inbox) => inbox.channel) ?? []; + const conversationId = + conversationIdOverride !== undefined + ? conversationIdOverride + : (cachedListEntry?.conversation?.id ?? null); + + if (isLoading) { + return ( + <Screen padded> + <Skeleton height={72} borderRadius={36} style={{ alignSelf: 'center' }} /> + <View style={{ height: spacing.md }} /> + <Skeleton height={16} /> + </Screen> + ); + } + + if (error) { + const normalized = normalizeApiError(error); + if (normalized.kind === 'workspaceBlocked') { + return ( + <Screen> + <WorkspaceBlockedGate reason={normalized.reason} message={normalized.message} /> + </Screen> + ); + } + return ( + <Screen padded> + <ErrorBanner + message={describeApiError(error, t)} + actionLabel={t('common.retry')} + onAction={() => refetch()} + /> + </Screen> + ); + } + + if (!contact) { + return ( + <Screen padded> + <EmptyState title={t('errors.notFound')} /> + </Screen> + ); + } + + function handleAssign(userId: string | null) { + if (!contact) return; + assignMutation.mutate({ contactIds: [contact.id], assignedId: userId }); + assignmentSheetRef.current?.close(); + } + + return ( + <Screen> + <ScrollView + refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={() => refetch()} />} + > + <ContactHeader + contact={contact} + origin={origin} + channels={channels} + conversationId={conversationId} + onAssign={workspaceId ? () => assignmentSheetRef.current?.expand() : undefined} + /> + <View style={{ paddingHorizontal: spacing.md, paddingVertical: spacing.sm }}> + <SegmentedTabs<Tab> + value={tab} + onChange={setTab} + options={[ + { value: 'info', label: t('contacts.tabInfo') }, + { value: 'tags', label: t('contacts.tabTags') }, + { value: 'notes', label: t('contacts.tabNotes') }, + { value: 'sequences', label: t('contacts.tabSequences') }, + ]} + /> + </View> + {tab === 'info' ? <InfoTab contact={contact} workspaceId={workspaceId} /> : null} + {tab === 'tags' && workspaceId ? ( + <TagsTab contact={contact} workspaceId={workspaceId} /> + ) : null} + {tab === 'notes' ? <NotesTab contact={contact} workspaceId={workspaceId} /> : null} + {tab === 'sequences' ? <SequencesTab contact={contact} workspaceId={workspaceId} /> : null} + </ScrollView> + {workspaceId ? ( + <AssignmentSheet + sheetRef={assignmentSheetRef} + workspaceId={workspaceId} + onAssign={handleAssign} + /> + ) : null} + </Screen> + ); +} diff --git a/src/features/contacts/contact-row.tsx b/src/features/contacts/contact-row.tsx new file mode 100644 index 0000000..b9e079d --- /dev/null +++ b/src/features/contacts/contact-row.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +import { Avatar } from '@/components/ui/avatar'; +import { ListItem } from '@/components/ui/list-item'; +import type { ContactListItem } from '@/features/contacts/api/use-contacts-infinite'; +import { usePermissions } from '@/features/permissions/use-permissions'; +import { maskEmail, maskPhone } from '@/features/contacts/pii-mask'; + +interface ContactRowProps { + contact: ContactListItem; + workspaceId: string | null; + onPress: () => void; +} + +/** PII masking is gated on `usePermissions` — a member without `emailAndPhone` sees masked + * email/phone here and in info-tab.tsx, not just a decorative blur. */ +export function ContactRow({ contact, workspaceId, onPress }: ContactRowProps) { + const { t } = useTranslation(); + const permissions = usePermissions(workspaceId); + const name = contact.fullName ?? contact.email ?? contact.phoneNumber ?? t('contacts.unknown'); + const canSeePii = permissions.canSeeEmailAndPhone; + const subtitle = contact.email + ? canSeePii + ? contact.email + : maskEmail(contact.email) + : contact.phoneNumber + ? canSeePii + ? contact.phoneNumber + : maskPhone(contact.phoneNumber) + : undefined; + + return ( + <ListItem + title={name} + subtitle={subtitle} + leading={<Avatar uri={contact.avatar} name={name} />} + onPress={onPress} + showChevron + /> + ); +} diff --git a/src/features/contacts/info-tab.tsx b/src/features/contacts/info-tab.tsx new file mode 100644 index 0000000..1c2c6f3 --- /dev/null +++ b/src/features/contacts/info-tab.tsx @@ -0,0 +1,165 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList, BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { useRef, useState } from 'react'; +import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { SectionHeader } from '@/components/ui/section-header'; +import { Sheet, SheetHeader, SheetModal } from '@/components/ui/sheet'; +import { Text } from '@/components/ui/text'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { + useDeleteContactField, + useSetContactField, + useWorkspaceCustomFields, +} from '@/features/contacts/api/use-contact-fields'; +import { usePermissions } from '@/features/permissions/use-permissions'; +import { maskEmail, maskPhone } from '@/features/contacts/pii-mask'; +import { useTheme } from '@/theme/use-theme'; + +interface InfoTabProps { + contact: ContactDetail; + workspaceId: string | null; +} + +function Field({ label, value }: { label: string; value: string | null }) { + const { spacing } = useTheme(); + if (!value) return null; + return ( + <View style={{ paddingHorizontal: spacing.md, paddingVertical: spacing.xs }}> + <Text variant="caption" color="secondary"> + {label} + </Text> + <Text variant="body">{value}</Text> + </View> + ); +} + +type CustomFieldCatalogEntry = { id: string; name: string; type: string }; + +/** + * Standard fields + `customFields` (embedded on the contact-detail response — see + * use-contact-detail.ts), rendered inline (no own scroll container — `ContactPanel` owns the + * single scroll surface all tabs render into). Email/phone are masked when the signed-in member + * lacks the `emailAndPhone` permission bit. Custom-field values are editable via a `SheetModal` + * (portaled above everything, including the tab bar) rather than the previous hand-rolled + * absolutely-positioned overlay containing a `BottomSheetTextInput` outside any real sheet. + */ +export function InfoTab({ contact, workspaceId }: InfoTabProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const permissions = usePermissions(workspaceId); + const canSeePii = permissions.canSeeEmailAndPhone; + + const { data: catalog } = useWorkspaceCustomFields(workspaceId); + const setField = useSetContactField(workspaceId ?? '', contact.id); + const deleteField = useDeleteContactField(workspaceId ?? '', contact.id); + + const catalogSheetRef = useRef<BottomSheet>(null); + const editorSheetRef = useRef<BottomSheetModal>(null); + const [editingField, setEditingField] = useState<CustomFieldCatalogEntry | null>(null); + const [draftValue, setDraftValue] = useState(''); + + const setFieldValues = new Map(contact.customFields.map((field) => [field.id, field.value])); + + function openEditor(field: CustomFieldCatalogEntry) { + setEditingField(field); + setDraftValue(setFieldValues.get(field.id) ?? ''); + catalogSheetRef.current?.close(); + editorSheetRef.current?.present(); + } + + function closeEditor() { + editorSheetRef.current?.dismiss(); + setEditingField(null); + } + + function saveDraft() { + if (!editingField) return; + const trimmed = draftValue.trim(); + if (trimmed) { + setField.mutate({ customFieldId: editingField.id, value: trimmed }); + } else if (setFieldValues.has(editingField.id)) { + deleteField.mutate(editingField.id); + } + closeEditor(); + } + + return ( + <View> + <Field + label={t('contacts.email')} + value={contact.email ? (canSeePii ? contact.email : maskEmail(contact.email)) : null} + /> + <Field + label={t('contacts.phone')} + value={ + contact.phoneNumber + ? canSeePii + ? contact.phoneNumber + : maskPhone(contact.phoneNumber) + : null + } + /> + <Field label={t('contacts.locale')} value={contact.locale} /> + <Field label={t('contacts.timezone')} value={contact.timezone} /> + <Field + label={t('contacts.location')} + value={[contact.city, contact.state, contact.country].filter(Boolean).join(', ') || null} + /> + + <SectionHeader title={t('contacts.customFields')} /> + {contact.customFields.map((field) => ( + <ListItem + key={field.id} + title={field.name} + subtitle={field.value} + trailing={<Icon name="pencil-outline" size={18} color={colors.textSecondary} />} + onPress={() => openEditor(field)} + /> + ))} + <ListItem + title={t('contacts.addCustomField')} + trailing={<Icon name="add-circle-outline" size={20} color={colors.brand} />} + onPress={() => catalogSheetRef.current?.expand()} + /> + + <Sheet ref={catalogSheetRef} snapPoints={['50%', '80%']}> + <BottomSheetFlatList + data={(catalog ?? []) as CustomFieldCatalogEntry[]} + keyExtractor={(field) => field.id} + renderItem={({ item }) => ( + <ListItem + title={item.name} + subtitle={setFieldValues.get(item.id)} + onPress={() => openEditor(item)} + /> + )} + /> + </Sheet> + + <SheetModal ref={editorSheetRef} snapPoints={['40%']} onDismiss={() => setEditingField(null)}> + <SheetHeader title={editingField?.name ?? ''} onClose={closeEditor} /> + <View style={{ paddingHorizontal: 16, gap: 12 }}> + <BottomSheetTextInput + value={draftValue} + onChangeText={setDraftValue} + autoFocus + style={{ + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 8, + padding: 10, + color: colors.textPrimary, + }} + /> + <Button label={t('common.save')} onPress={saveDraft} fullWidth /> + </View> + </SheetModal> + </View> + ); +} diff --git a/src/features/contacts/lib/find-contact-in-cache.test.ts b/src/features/contacts/lib/find-contact-in-cache.test.ts new file mode 100644 index 0000000..59fbf85 --- /dev/null +++ b/src/features/contacts/lib/find-contact-in-cache.test.ts @@ -0,0 +1,104 @@ +import { QueryClient } from '@tanstack/react-query'; + +import type { + ContactListItem, + ListContactsResponse, +} from '@/features/contacts/api/use-contacts-infinite'; + +import { findContactInListCache } from './find-contact-in-cache'; + +function fakeContact(id: string, overrides: Partial<ContactListItem> = {}): ContactListItem { + return { + id, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + avatar: null, + phoneNumber: null, + email: null, + fullName: `Contact ${id}`, + blockedAt: null, + workspaceId: 'ws-1', + contactInboxes: [], + conversation: null, + ...overrides, + } as unknown as ContactListItem; +} + +function fakePage(items: ContactListItem[]): ListContactsResponse { + return { data: items, pageCount: 1, totalCount: items.length, totalCountCapped: false }; +} + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +describe('findContactInListCache', () => { + it('finds a contact cached in a list query page for the given workspace', () => { + const queryClient = createTestQueryClient(); + const target = fakeContact('contact-1', { + contactInboxes: [{ channel: 'whatsapp' }] as unknown as ContactListItem['contactInboxes'], + conversation: { id: 'conv-1' } as unknown as ContactListItem['conversation'], + }); + queryClient.setQueryData(['ws', 'ws-1', 'contacts', 'list', { keyword: '' }], { + pages: [fakePage([fakeContact('contact-0'), target])], + pageParams: [1], + }); + + const found = findContactInListCache(queryClient, 'ws-1', 'contact-1'); + + expect(found?.id).toBe('contact-1'); + expect(found?.conversation?.id).toBe('conv-1'); + }); + + it('searches across every cached list query (different keyword filters)', () => { + const queryClient = createTestQueryClient(); + const target = fakeContact('contact-2'); + queryClient.setQueryData(['ws', 'ws-1', 'contacts', 'list', { keyword: '' }], { + pages: [fakePage([fakeContact('contact-0')])], + pageParams: [1], + }); + queryClient.setQueryData(['ws', 'ws-1', 'contacts', 'list', { keyword: 'jane' }], { + pages: [fakePage([target])], + pageParams: [1], + }); + + expect(findContactInListCache(queryClient, 'ws-1', 'contact-2')?.id).toBe('contact-2'); + }); + + it('returns undefined when the contact is not cached', () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', 'ws-1', 'contacts', 'list', {}], { + pages: [fakePage([fakeContact('contact-0')])], + pageParams: [1], + }); + + expect(findContactInListCache(queryClient, 'ws-1', 'missing')).toBeUndefined(); + }); + + it('does not match contacts cached under a different workspace', () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', 'ws-other', 'contacts', 'list', {}], { + pages: [fakePage([fakeContact('contact-1')])], + pageParams: [1], + }); + + expect(findContactInListCache(queryClient, 'ws-1', 'contact-1')).toBeUndefined(); + }); + + it('returns undefined when no list queries are cached at all', () => { + const queryClient = createTestQueryClient(); + expect(findContactInListCache(queryClient, 'ws-1', 'contact-1')).toBeUndefined(); + }); +}); diff --git a/src/features/contacts/lib/find-contact-in-cache.ts b/src/features/contacts/lib/find-contact-in-cache.ts new file mode 100644 index 0000000..caf550a --- /dev/null +++ b/src/features/contacts/lib/find-contact-in-cache.ts @@ -0,0 +1,39 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import type { + ContactListItem, + ListContactsResponse, +} from '@/features/contacts/api/use-contacts-infinite'; + +/** + * Scans every cached contacts-list infinite query (any keyword) for a row matching `contactId`. + * + * `useContactDetail`'s `getContactAuthenticatedAPI` response does NOT embed `contactInboxes` or + * `conversation` (verified directly against the generated schema — only the contacts-LIST + * operations, `listContactsAuthenticatedAPI`/`listContactsByPOSTAuthenticatedAPI`, embed those). + * So the contact-header's channel chips and "Message" CTA source `contactInboxes`/`conversation` + * from whichever list page the user navigated from, mirroring + * `find-conversation-in-cache.ts`'s approach on the conversations side. Falls back to `undefined` + * on a cache miss (e.g. a cold deep link straight to a contact) — the header degrades gracefully + * in that case (no channel chips, "Message" disabled). + */ +export function findContactInListCache( + queryClient: QueryClient, + workspaceId: string, + contactId: string, +): ContactListItem | undefined { + return queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'contacts' && + query.queryKey[3] === 'list', + }) + .flatMap((query) => { + const data = query.state.data as { pages: ListContactsResponse[] } | undefined; + return data?.pages.flatMap((page) => page.data) ?? []; + }) + .find((item) => item.id === contactId); +} diff --git a/src/features/contacts/notes-tab.tsx b/src/features/contacts/notes-tab.tsx new file mode 100644 index 0000000..711317d --- /dev/null +++ b/src/features/contacts/notes-tab.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Card } from '@/components/ui/surface'; +import { EmptyState } from '@/components/ui/empty-state'; +import { RelativeTime } from '@/components/ui/relative-time'; +import { Text } from '@/components/ui/text'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { useWorkspaceMembersList } from '@/features/permissions/use-permissions'; +import { useTheme } from '@/theme/use-theme'; + +interface NotesTabProps { + contact: ContactDetail; + workspaceId: string | null; +} + +/** Read-only — `contactNotes` (`{ id, text, createdAt, createdById }`) is embedded on the + * contact-detail response, so no separate fetch is needed. Author name is resolved by matching + * `createdById` against the workspace members list (shared with assignment-sheet's query), with a + * generic fallback label for notes whose author is no longer a member or wasn't found. */ +export function NotesTab({ contact, workspaceId }: NotesTabProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const { data: members } = useWorkspaceMembersList(workspaceId ?? ''); + + function authorName(createdById: string | null): string { + if (!createdById) return t('contacts.unknownAuthor'); + const member = members?.find((m) => m.userId === createdById); + return member?.user.name ?? t('contacts.unknownAuthor'); + } + + if (contact.contactNotes.length === 0) { + return <EmptyState icon="document-text-outline" title={t('contacts.noNotes')} />; + } + + const sortedNotes = [...contact.contactNotes].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return ( + <View style={{ padding: spacing.md, gap: spacing.sm }}> + {sortedNotes.map((note) => ( + <Card key={note.id} padding="md" style={styles.card}> + <View style={[styles.header, { gap: spacing.xs }]}> + <Text variant="bodyStrong" numberOfLines={1} style={styles.authorName}> + {authorName(note.createdById)} + </Text> + <RelativeTime date={note.createdAt} /> + </View> + <Text variant="body" color="secondary"> + {note.text} + </Text> + </Card> + ))} + </View> + ); +} + +const styles = StyleSheet.create({ + card: { + gap: 4, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + }, + authorName: { + flex: 1, + }, +}); diff --git a/src/features/contacts/pii-mask.test.ts b/src/features/contacts/pii-mask.test.ts new file mode 100644 index 0000000..1b55f5a --- /dev/null +++ b/src/features/contacts/pii-mask.test.ts @@ -0,0 +1,39 @@ +import { maskEmail, maskPhone } from '@/features/contacts/pii-mask'; + +describe('maskEmail', () => { + it('keeps the first 2 local-part chars and the full domain', () => { + expect(maskEmail('johndoe@example.com')).toBe('jo*****@example.com'); + }); + + it('returns *** when there is no domain', () => { + expect(maskEmail('notanemail')).toBe('***'); + }); + + it('pads short local parts to at least 3 asterisks', () => { + expect(maskEmail('ab@example.com')).toBe('ab***@example.com'); + }); + + it('returns *** for an empty local part', () => { + expect(maskEmail('@example.com')).toBe('***'); + }); + + it('splits on the last @ so a quoted local part containing @ is not mistaken for the domain', () => { + expect(maskEmail('"john@doe"@example.com')).toBe('"j********@example.com'); + }); +}); + +describe('maskPhone', () => { + it('keeps only the last 2 digits visible, sized off the digit count not the raw string', () => { + // "+15551234567" has 11 digits — the asterisk count must reflect that, not the 12-char string. + expect(maskPhone('+15551234567')).toBe('*********67'); + }); + + it('masks the whole string when there are 2 or fewer digits', () => { + expect(maskPhone('12')).toBe('**'); + }); + + it('strips non-digit characters before counting the last 2 visible digits', () => { + // "(555) 123-4567" has 10 digits — 8 asterisks + the last 2, not sized off the 14-char string. + expect(maskPhone('(555) 123-4567')).toBe('********67'); + }); +}); diff --git a/src/features/contacts/pii-mask.ts b/src/features/contacts/pii-mask.ts new file mode 100644 index 0000000..de15e57 --- /dev/null +++ b/src/features/contacts/pii-mask.ts @@ -0,0 +1,20 @@ +/** Masks an email as `ab***@domain.com` — keeps the first 2 local-part chars and the domain so a + * restricted member can still recognize a contact without seeing the full address. Splits on the + * LAST `@` (not the first) since a quoted local-part can itself legally contain `@`. */ +export function maskEmail(email: string): string { + const atIndex = email.lastIndexOf('@'); + if (atIndex <= 0) return '***'; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const visible = local.slice(0, 2); + return `${visible}${'*'.repeat(Math.max(local.length - visible.length, 3))}@${domain}`; +} + +/** Masks a phone number, keeping only the last 2 digits visible. Asterisk count is derived from + * `digits` (not the raw string) so formatting characters (`-`, spaces, parens) don't inflate the + * mask length beyond the actual digit count. */ +export function maskPhone(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.length <= 2) return '*'.repeat(digits.length); + return `${'*'.repeat(digits.length - 2)}${digits.slice(-2)}`; +} diff --git a/src/features/contacts/sequences-tab.tsx b/src/features/contacts/sequences-tab.tsx new file mode 100644 index 0000000..655b1d6 --- /dev/null +++ b/src/features/contacts/sequences-tab.tsx @@ -0,0 +1,74 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { useRef } from 'react'; +import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ListItem } from '@/components/ui/list-item'; +import { RelativeTime } from '@/components/ui/relative-time'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { SequencePickerSheet } from '@/features/sequences/components/sequence-picker-sheet'; +import { useTheme } from '@/theme/use-theme'; + +interface SequencesTabProps { + contact: ContactDetail; + workspaceId: string | null; +} + +/** + * `contactsOnSequences` embeds the full `sequence` object per enrollment, so no separate fetch is + * needed for the read-only enrollment list below. No longer read-only overall, though: an "Add to + * sequence" button opens `SequencePickerSheet` (flag-gated — see `src/config/features.ts` and + * `sequence-picker-sheet.tsx`'s own "Coming soon" behavior while `FEATURES.sendSequence` is off). + * Renders inline (no own scroll container — `ContactPanel` owns the single scroll surface). + */ +export function SequencesTab({ contact, workspaceId }: SequencesTabProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const pickerSheetRef = useRef<BottomSheet>(null); + + return ( + <View style={{ paddingBottom: spacing.md }}> + <View style={{ paddingHorizontal: spacing.md, paddingTop: spacing.md }}> + <Button + label={t('contacts.addToSequence')} + variant="tonal" + icon="add-circle-outline" + onPress={() => pickerSheetRef.current?.expand()} + disabled={!workspaceId} + /> + </View> + + {contact.contactsOnSequences.length === 0 ? ( + <EmptyState icon="git-network-outline" title={t('contacts.noSequences')} /> + ) : ( + contact.contactsOnSequences.map((enrollment) => ( + <View key={enrollment.sequenceId}> + <ListItem + title={enrollment.sequence.name} + subtitle={t('contacts.sequenceStep', { step: enrollment.currentStep })} + trailing={ + <Badge color={enrollment.completedAt ? colors.success : colors.surface2}> + {enrollment.completedAt + ? t('contacts.completed') + : (enrollment.status ?? t('contacts.active'))} + </Badge> + } + /> + <RelativeTime date={enrollment.enrolledAt} /> + </View> + )) + )} + + {workspaceId ? ( + <SequencePickerSheet + sheetRef={pickerSheetRef} + workspaceId={workspaceId} + contactId={contact.id} + /> + ) : null} + </View> + ); +} diff --git a/src/features/contacts/tags-tab.tsx b/src/features/contacts/tags-tab.tsx new file mode 100644 index 0000000..8cdd08b --- /dev/null +++ b/src/features/contacts/tags-tab.tsx @@ -0,0 +1,84 @@ +import { StyleSheet, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Chip } from '@/components/ui/chip'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { SectionHeader } from '@/components/ui/section-header'; +import type { ContactDetail } from '@/features/contacts/api/use-contact-detail'; +import { + useAddContactTag, + useRemoveContactTag, + useWorkspaceTags, +} from '@/features/contacts/api/use-contact-tags'; +import { useTheme } from '@/theme/use-theme'; + +interface TagsTabProps { + contact: ContactDetail; + workspaceId: string; +} + +/** + * `contact.tags` comes embedded from useContactDetail — these are real workspace tags, unrelated + * to the conversations feature's `tag-chips.tsx` (which renders derived status chips like + * unread/followUp, not contact tags — the conversations-list response has no `tags` field at all, + * confirmed against the schema). There is nothing to keep in sync with the inbox there. + * + * Renders inline (no own scroll/list container — `ContactPanel` owns the single scroll surface). + * Assigned tags render as removable `Chip`s (optimistic add/remove — see use-contact-tags.ts). + */ +export function TagsTab({ contact, workspaceId }: TagsTabProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const { data: catalog } = useWorkspaceTags(workspaceId); + const addTag = useAddContactTag(workspaceId, contact.id); + const removeTag = useRemoveContactTag(workspaceId, contact.id); + + const assignedIds = new Set(contact.tags.map((tag) => tag.id)); + const available = (catalog ?? []).filter((tag) => !assignedIds.has(tag.id)); + + return ( + <View style={styles.container}> + <SectionHeader title={t('contacts.assignedTags')} /> + {contact.tags.length === 0 ? ( + <EmptyState icon="pricetag-outline" title={t('contacts.noTags')} /> + ) : ( + <View style={[styles.chipRow, { paddingHorizontal: spacing.md, gap: spacing.xs }]}> + {contact.tags.map((tag) => ( + <Chip + key={tag.id} + label={tag.name} + tone="brand" + onRemove={() => removeTag.mutate(tag.id)} + /> + ))} + </View> + )} + + {available.length > 0 ? ( + <> + <SectionHeader title={t('contacts.addTag')} /> + {available.map((item) => ( + <ListItem + key={item.id} + title={item.name} + trailing={<Icon name="add-circle-outline" size={20} color={colors.brand} />} + onPress={() => addTag.mutate(item)} + /> + ))} + </> + ) : null} + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); diff --git a/src/features/conversations/api/use-conversation-actions.test.tsx b/src/features/conversations/api/use-conversation-actions.test.tsx new file mode 100644 index 0000000..68af9bf --- /dev/null +++ b/src/features/conversations/api/use-conversation-actions.test.tsx @@ -0,0 +1,154 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { apiClient } from '@/api/client'; +import { queryKeys } from '@/api/query-keys'; +import { + useDisableBot, + useMarkConversationRead, +} from '@/features/conversations/api/use-conversation-actions'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; + +jest.mock('@/api/client', () => ({ + apiClient: { + POST: jest.fn(), + }, +})); + +const mockedPost = apiClient.POST as jest.Mock; + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + mockedPost.mockReset(); + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +function fakeConversation(overrides: Partial<ConversationListItem> = {}): ConversationListItem { + return { + id: 'conv-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: true, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: null, + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: 'ws-1', + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [], + messages: [], + contact: null, + assignedUser: null, + assignedInboxTeam: null, + ...overrides, + } as unknown as ConversationListItem; +} + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +describe('patchConversationDetail (via useMarkConversationRead)', () => { + it('optimistically patches a cached conversation-detail query alongside the list cache', async () => { + mockedPost.mockResolvedValue({ data: {}, error: undefined }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.conversations.detail('ws-1', 'conv-1'); + queryClient.setQueryData(detailKey, fakeConversation({ agentLastReadAt: null })); + queryClient.setQueryData(['ws', 'ws-1', 'conversations', 'list', {}], { + pages: [{ data: [fakeConversation({ agentLastReadAt: null })], nextCursor: null }], + pageParams: [undefined], + }); + + const { result } = await renderHook(() => useMarkConversationRead('ws-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate('conv-1'); + }); + + await waitFor(() => { + const detail = queryClient.getQueryData<ConversationListItem>(detailKey); + expect(detail?.agentLastReadAt).not.toBeNull(); + }); + }); + + it('leaves the detail cache untouched when nothing is cached for that conversation', async () => { + mockedPost.mockResolvedValue({ data: {}, error: undefined }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.conversations.detail('ws-1', 'conv-1'); + + const { result } = await renderHook(() => useMarkConversationRead('ws-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate('conv-1'); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(queryClient.getQueryData(detailKey)).toBeUndefined(); + }); +}); + +describe('useDisableBot', () => { + it('invalidates the detail query for each disabled conversation on success', async () => { + mockedPost.mockResolvedValue({ data: {}, error: undefined }); + const queryClient = createTestQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const detailKey = queryKeys.ws.conversations.detail('ws-1', 'conv-1'); + queryClient.setQueryData(detailKey, fakeConversation()); + + const { result } = await renderHook(() => useDisableBot('ws-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate(['conv-1']); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: detailKey }); + }); + + it('rolls back the optimistic botEnabled patch on error', async () => { + mockedPost.mockResolvedValue({ data: undefined, error: { message: 'boom' } }); + const queryClient = createTestQueryClient(); + const detailKey = queryKeys.ws.conversations.detail('ws-1', 'conv-1'); + queryClient.setQueryData(detailKey, fakeConversation({ botEnabled: true })); + + const { result } = await renderHook(() => useDisableBot('ws-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate(['conv-1']); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(queryClient.getQueryData<ConversationListItem>(detailKey)?.botEnabled).toBe(true); + }); +}); diff --git a/src/features/conversations/api/use-conversation-actions.ts b/src/features/conversations/api/use-conversation-actions.ts new file mode 100644 index 0000000..34f02e3 --- /dev/null +++ b/src/features/conversations/api/use-conversation-actions.ts @@ -0,0 +1,389 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { isWorkspaceQuery, queryKeys } from '@/api/query-keys'; +import type { + ConversationListItem, + ListConversationsResponse, +} from '@/features/conversations/api/use-conversations-infinite'; + +/** + * Conversation action mutations, exactly matching the request shapes read from + * `conversationsAPI.{assignConversations,archiveConversations,unarchiveConversations,enableBot, + * disableBot,readConversation,unreadConversation,followConversation,unfollowConversation} + * AuthenticatedAPI` in src/api/generated/schema.ts: + * + * - assign: POST /conversations/assign body `{ contactIds: string[], assignedId: string | null }` + * — keyed by CONTACT id, not conversation id (per the real schema). + * - archive/unarchive/enableBot/disableBot: POST .../{action} body `{ ids: string[] }` — batch, + * conversation ids. + * - read/unread/follow/unfollow: POST .../{id}/{action}, path param, no body, single conversation. + * + * All apply an optimistic patch across every cached infinite-query page for the conversations + * list (any filter combination), rolling back on error via the snapshot each mutation captures. + */ + +type InfiniteData = { pages: ListConversationsResponse[]; pageParams: unknown[] }; + +function patchConversationInCache( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, + conversationId: string, + patch: Partial<ConversationListItem>, +) { + const previous = new Map<readonly unknown[], InfiniteData | undefined>(); + + queryClient + .getQueryCache() + .findAll({ + // `queryKey[3] === 'list'` narrows this to the paginated list queries specifically — the + // detail query lives under the same `['ws', id, 'conversations', ...]` prefix but as + // `['ws', id, 'conversations', 'detail', conversationId]` with a single-object cache shape + // (`{ pages, data }` doesn't apply), so without this check the `.pages.map(...)` below + // throws whenever a detail query happens to be cached for the same workspace. + predicate: (query) => + isWorkspaceQuery(query.queryKey, workspaceId) && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .forEach((query) => { + previous.set(query.queryKey, query.state.data as InfiniteData | undefined); + queryClient.setQueryData<InfiniteData>(query.queryKey, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + data: page.data.map((item) => + item.id === conversationId ? { ...item, ...patch } : item, + ), + })), + }; + }); + }); + + return previous; +} + +function rollback( + queryClient: ReturnType<typeof useQueryClient>, + snapshot: Map<readonly unknown[], InfiniteData | undefined>, +) { + snapshot.forEach((data, key) => { + queryClient.setQueryData(key, data); + }); +} + +/** Cancels every in-flight conversations-list query for this workspace (any filter combination) + * before an optimistic patch runs, so a refetch that resolves mid-mutation can't clobber the + * patch with stale server data. */ +function cancelConversationQueries( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, +): Promise<void> { + return queryClient.cancelQueries({ + predicate: (query) => + isWorkspaceQuery(query.queryKey, workspaceId) && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }); +} + +/** The conversation-detail query (`useConversationDetail`) caches a single object under + * `queryKeys.ws.conversations.detail(...)` — a different shape from the paginated list queries + * above (`{ pages, pageParams }`). Every list-cache mutation here should also patch the detail + * query when it's cached for the same conversation, so a screen reading the detail query (chat + * header, contact-nested route) doesn't go stale relative to the list the user came from. Returns + * the previous detail data (or undefined if nothing was cached) so callers can roll it back. */ +function patchConversationDetail( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, + conversationId: string, + patch: Partial<ConversationListItem>, +) { + const detailKey = queryKeys.ws.conversations.detail(workspaceId, conversationId); + const previous = queryClient.getQueryData<ConversationListItem>(detailKey); + + if (previous) { + queryClient.setQueryData<ConversationListItem>(detailKey, { ...previous, ...patch }); + } + + return { key: detailKey, previous }; +} + +function rollbackDetail( + queryClient: ReturnType<typeof useQueryClient>, + snapshot: { key: readonly unknown[]; previous: ConversationListItem | undefined } | undefined, +) { + if (!snapshot || !snapshot.previous) return; + queryClient.setQueryData(snapshot.key, snapshot.previous); +} + +interface SingleActionSnapshot { + list: Map<readonly unknown[], InfiniteData | undefined>; + detail: { key: readonly unknown[]; previous: ConversationListItem | undefined }; +} + +/** Patches both the list cache (every filter combination) and the detail cache for a single + * conversation id, returning a combined snapshot `onError` can roll back with `rollbackSingle`. */ +function patchSingleConversation( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, + conversationId: string, + patch: Partial<ConversationListItem>, +): SingleActionSnapshot { + return { + list: patchConversationInCache(queryClient, workspaceId, conversationId, patch), + detail: patchConversationDetail(queryClient, workspaceId, conversationId, patch), + }; +} + +function rollbackSingle( + queryClient: ReturnType<typeof useQueryClient>, + snapshot: SingleActionSnapshot | undefined, +) { + if (!snapshot) return; + rollback(queryClient, snapshot.list); + rollbackDetail(queryClient, snapshot.detail); +} + +export function useMarkConversationRead(workspaceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + const { error } = await apiClient.POST('/workspaces/{workspaceId}/conversations/{id}/read', { + params: { path: { workspaceId, id: conversationId } }, + }); + if (error) throw new ApiError(error); + }, + onMutate: async (conversationId) => { + await cancelConversationQueries(queryClient, workspaceId); + return patchSingleConversation(queryClient, workspaceId, conversationId, { + agentLastReadAt: new Date().toISOString(), + }); + }, + onError: (_err, _vars, snapshot) => rollbackSingle(queryClient, snapshot), + }); +} + +export function useMarkConversationUnread(workspaceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + const { error } = await apiClient.POST( + '/workspaces/{workspaceId}/conversations/{id}/unread', + { + params: { path: { workspaceId, id: conversationId } }, + }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (conversationId) => { + await cancelConversationQueries(queryClient, workspaceId); + return patchSingleConversation(queryClient, workspaceId, conversationId, { + agentLastReadAt: null, + }); + }, + onError: (_err, _vars, snapshot) => rollbackSingle(queryClient, snapshot), + }); +} + +export function useFollowConversation(workspaceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + const { error } = await apiClient.POST( + '/workspaces/{workspaceId}/conversations/{id}/follow', + { + params: { path: { workspaceId, id: conversationId } }, + }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (conversationId) => { + await cancelConversationQueries(queryClient, workspaceId); + return patchSingleConversation(queryClient, workspaceId, conversationId, { followed: true }); + }, + onError: (_err, _vars, snapshot) => rollbackSingle(queryClient, snapshot), + }); +} + +export function useUnfollowConversation(workspaceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + const { error } = await apiClient.POST( + '/workspaces/{workspaceId}/conversations/{id}/unfollow', + { + params: { path: { workspaceId, id: conversationId } }, + }, + ); + if (error) throw new ApiError(error); + }, + onMutate: async (conversationId) => { + await cancelConversationQueries(queryClient, workspaceId); + return patchSingleConversation(queryClient, workspaceId, conversationId, { + followed: false, + }); + }, + onError: (_err, _vars, snapshot) => rollbackSingle(queryClient, snapshot), + }); +} + +/** Batch actions (archive/unarchive/bot toggle) patch every affected id in cache — both the list + * cache (every filter combination) and the detail cache — since the backend accepts a batch but + * the UI here always calls with a single-id array (swipe/long-press actions) — batching UI is a + * later concern if ever needed. `onSettledIds` runs after the mutation settles (success or + * error) with the affected ids, for callers that need a side effect beyond the optimistic patch + * (e.g. `useDisableBot` invalidating the detail query so the server-computed `botResumeAt` + * arrives). */ +function useBatchConversationAction( + workspaceId: string, + path: + | '/workspaces/{workspaceId}/conversations/archive' + | '/workspaces/{workspaceId}/conversations/unarchive' + | '/workspaces/{workspaceId}/conversations/enable-bot' + | '/workspaces/{workspaceId}/conversations/disable-bot', + buildPatch: () => Partial<ConversationListItem>, + onSuccessIds?: (queryClient: ReturnType<typeof useQueryClient>, ids: string[]) => void, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (ids: string[]) => { + const { error } = await apiClient.POST(path, { + params: { path: { workspaceId } }, + body: { ids }, + }); + if (error) throw new ApiError(error); + }, + onMutate: async (ids: string[]) => { + await cancelConversationQueries(queryClient, workspaceId); + const patch = buildPatch(); + return ids.map((id) => patchSingleConversation(queryClient, workspaceId, id, patch)); + }, + onError: (_err, _vars, snapshots) => { + snapshots?.forEach((snapshot) => rollbackSingle(queryClient, snapshot)); + }, + onSuccess: (_data, ids) => onSuccessIds?.(queryClient, ids), + }); +} + +export function useArchiveConversations(workspaceId: string) { + return useBatchConversationAction( + workspaceId, + '/workspaces/{workspaceId}/conversations/archive', + () => ({ archivedAt: new Date().toISOString() }), + ); +} + +export function useUnarchiveConversations(workspaceId: string) { + return useBatchConversationAction( + workspaceId, + '/workspaces/{workspaceId}/conversations/unarchive', + () => ({ archivedAt: null }), + ); +} + +export function useEnableBot(workspaceId: string) { + return useBatchConversationAction( + workspaceId, + '/workspaces/{workspaceId}/conversations/enable-bot', + () => ({ botEnabled: true }), + ); +} + +/** Disabling the bot triggers a 24h server-side pause (`botResumeAt`) — the optimistic patch above + * only knows `botEnabled: false`, it can't predict the server-computed resume timestamp. Once the + * mutation succeeds, invalidate the detail query for each affected conversation so a refetch + * brings in the real `botResumeAt` (used by `isBotActive` to show "paused until…" rather than + * just "off"). The list cache is left as-is here — it already shows a reasonable "not handled by + * bot" state from the optimistic patch, and invalidating N list queries on every disable is + * unnecessary churn; the detail query is what chat-header and contact screens read closely. */ +export function useDisableBot(workspaceId: string) { + return useBatchConversationAction( + workspaceId, + '/workspaces/{workspaceId}/conversations/disable-bot', + () => ({ botEnabled: false }), + (queryClient, ids) => { + ids.forEach((id) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.ws.conversations.detail(workspaceId, id), + }); + }); + }, + ); +} + +/** Patches `assignedUserId` on every cached conversation row whose `contactId` is in the given + * set — assign is keyed by contact, not conversation, so a contact can back multiple + * conversations (e.g. across channels) that all need the same optimistic update. */ +function patchAssignedUserByContact( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, + contactIds: string[], + assignedId: string | null, +) { + const contactIdSet = new Set(contactIds); + const previous = new Map<readonly unknown[], InfiniteData | undefined>(); + + queryClient + .getQueryCache() + .findAll({ + // See the matching comment in `patchConversationInCache` above — `queryKey[3] === 'list'` + // excludes the differently-shaped detail query from this list-only patch. + predicate: (query) => + isWorkspaceQuery(query.queryKey, workspaceId) && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .forEach((query) => { + previous.set(query.queryKey, query.state.data as InfiniteData | undefined); + queryClient.setQueryData<InfiniteData>(query.queryKey, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + data: page.data.map((item) => + contactIdSet.has(item.contactId) ? { ...item, assignedUserId: assignedId } : item, + ), + })), + }; + }); + }); + + return previous; +} + +/** Assign is keyed by CONTACT id (not conversation id) per the real schema — pass the + * conversation's `contactId` field, and `assignedId: null` to unassign. */ +export function useAssignConversations(workspaceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + contactIds, + assignedId, + }: { + contactIds: string[]; + assignedId: string | null; + }) => { + const { error } = await apiClient.POST('/workspaces/{workspaceId}/conversations/assign', { + params: { path: { workspaceId } }, + body: { contactIds, assignedId }, + }); + if (error) throw new ApiError(error); + }, + onMutate: async ({ contactIds, assignedId }) => { + await cancelConversationQueries(queryClient, workspaceId); + return patchAssignedUserByContact(queryClient, workspaceId, contactIds, assignedId); + }, + onError: (_err, _vars, snapshot) => snapshot && rollback(queryClient, snapshot), + }); +} diff --git a/src/features/conversations/api/use-conversation-detail.ts b/src/features/conversations/api/use-conversation-detail.ts new file mode 100644 index 0000000..dae4b3d --- /dev/null +++ b/src/features/conversations/api/use-conversation-detail.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; + +interface UseConversationDetailOptions { + /** Synchronous placeholder resolver — e.g. `findConversationInListCache` — so a screen that + * navigated here from a list already showing this conversation can render instantly instead of + * flashing a "Conversation" fallback while the detail fetch is in flight (the bug this option + * exists to fix: a deep link straight into a conversation had no list cache to fall back on, + * and previously showed a hardcoded fallback name for the whole first fetch). */ + placeholderData?: () => ConversationListItem | undefined; +} + +/** `conversationsAPI.findConversationAuthenticatedAPI` — `GET /workspaces/{workspaceId}/conversations/{id}`. + * Used by the conversation-nested contact route to resolve `contactId` before rendering the shared + * ContactPanel (that route is keyed by conversationId, not contactId), and by the chat header to + * render conversation identity (name/channel/assignee/bot state) with an optional list-cache + * placeholder while the real fetch is in flight. */ +export function useConversationDetail( + workspaceId: string | null, + conversationId: string | null, + options: UseConversationDetailOptions = {}, +) { + return useQuery({ + queryKey: queryKeys.ws.conversations.detail(workspaceId ?? '', conversationId ?? ''), + enabled: workspaceId !== null && conversationId !== null, + placeholderData: options.placeholderData, + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/conversations/{id}', { + params: { path: { workspaceId: workspaceId!, id: conversationId! } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} diff --git a/src/features/conversations/api/use-conversations-infinite.ts b/src/features/conversations/api/use-conversations-infinite.ts new file mode 100644 index 0000000..e1c30b0 --- /dev/null +++ b/src/features/conversations/api/use-conversations-infinite.ts @@ -0,0 +1,83 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { getNextPageParam } from '@/api/pagination'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; +import { + conversationFiltersSnapshot, + useConversationFilters, +} from '@/features/conversations/stores/use-conversation-filters'; +import { usePermissions } from '@/features/permissions/use-permissions'; +import { useAuthStore } from '@/stores/use-auth-store'; + +/** + * Request/response types derived directly from the generated schema's `operations` map, so they + * always match `conversationsAPI.listConversationsByPOSTAuthenticatedAPI` exactly (verified by + * reading src/api/generated/schema.ts — do not hand-declare these, the operation gets regenerated + * whenever `pnpm generate:api` reruns). The response shape matches api/pagination.ts's + * `CursorPage<T>` (`{ data, nextCursor, prevCursor }`), so this hook uses the shared helpers. + */ +type ListConversationsOperation = + operations['conversationsAPI.listConversationsByPOSTAuthenticatedAPI']; +export type ListConversationsResponse = + ListConversationsOperation['responses'][200]['content']['application/json']; +export type ConversationListItem = ListConversationsResponse['data'][number]; + +const PER_PAGE = 30; + +/** + * `onlyAssignedContacts` (from `usePermissions`) forces `assignedId` to the current user's own + * id, overriding any manual "assigned to" filter — a restricted member must not be able to browse + * other agents' conversations by picking a different filter value. Unrestricted members keep + * whatever the filter store holds (including "unassigned"/other-user values). + */ +export function useConversationsInfinite(workspaceId: string | null) { + // `conversationFiltersSnapshot` builds a fresh object per call, and zustand v5's + // useSyncExternalStore compares selector results with Object.is (no shallow fallback, + // unlike v4) — without useShallow this re-renders on every render and infinite-loops. + const filters = useConversationFilters(useShallow(conversationFiltersSnapshot)); + const currentUserId = useAuthStore((state) => state.user?.id); + const { onlyAssignedContacts, isLoading: isPermissionsLoading } = usePermissions(workspaceId); + const effectiveAssignedId = onlyAssignedContacts + ? (currentUserId ?? filters.assignedId) + : filters.assignedId; + const effectiveFilters = useMemo( + () => ({ ...filters, assignedId: effectiveAssignedId }), + [filters, effectiveAssignedId], + ); + + return useInfiniteQuery({ + queryKey: queryKeys.ws.conversations.list(workspaceId ?? '', effectiveFilters), + // Waits for permissions to settle before firing the first fetch — `usePermissions` fails + // CLOSED (onlyAssignedContacts: true) while loading, but that fail-closed default would + // otherwise still be baked into this query's very first request/cache-key if it ran before + // the real permission bits were known, letting a restricted member's first page briefly use + // the wrong scope (or an unrestricted member's first page be needlessly scoped down). + enabled: workspaceId !== null && !isPermissionsLoading, + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }): Promise<ListConversationsResponse> => { + const { data, error } = await apiClient.POST('/workspaces/{workspaceId}/conversations/list', { + params: { path: { workspaceId: workspaceId! } }, + body: { + botCategory: effectiveFilters.botCategory, + assignedId: effectiveFilters.assignedId, + channel: effectiveFilters.channel, + status: effectiveFilters.status, + keyword: effectiveFilters.keyword || undefined, + botEnabled: effectiveFilters.botEnabled, + cursor: pageParam, + perPage: PER_PAGE, + }, + }); + if (error) { + throw new ApiError(error); + } + return data; + }, + getNextPageParam, + }); +} diff --git a/src/features/conversations/api/use-inbox-unread-count.test.tsx b/src/features/conversations/api/use-inbox-unread-count.test.tsx new file mode 100644 index 0000000..55fe447 --- /dev/null +++ b/src/features/conversations/api/use-inbox-unread-count.test.tsx @@ -0,0 +1,167 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import type { PropsWithChildren } from 'react'; + +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; +import { useInboxUnreadCount } from './use-inbox-unread-count'; + +const WORKSPACE_ID = 'ws-1'; + +function fakeConversation(overrides: Partial<ConversationListItem> = {}): ConversationListItem { + return { + id: 'conv-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: true, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: null, + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: WORKSPACE_ID, + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [], + messages: [], + contact: null, + assignedUser: null, + assignedInboxTeam: null, + ...overrides, + } as unknown as ConversationListItem; +} + +function unreadConversation(id: string): ConversationListItem { + return fakeConversation({ + id, + lastActivityAt: '2026-01-02T00:00:00.000Z', + agentLastReadAt: '2026-01-01T00:00:00.000Z', + }); +} + +function readConversation(id: string): ConversationListItem { + return fakeConversation({ + id, + lastActivityAt: '2026-01-01T00:00:00.000Z', + agentLastReadAt: '2026-01-02T00:00:00.000Z', + }); +} + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +describe('useInboxUnreadCount', () => { + it('returns 0 when nothing is cached', async () => { + const queryClient = createTestQueryClient(); + const { result } = await renderHook(() => useInboxUnreadCount(WORKSPACE_ID), { + wrapper: wrapper(queryClient), + }); + expect(result.current).toBe(0); + }); + + it('counts unread conversations across a single cached list page', async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', WORKSPACE_ID, 'conversations', 'list', {}], { + pages: [ + { + data: [unreadConversation('a'), readConversation('b'), unreadConversation('c')], + nextCursor: null, + }, + ], + pageParams: [undefined], + }); + + const { result } = await renderHook(() => useInboxUnreadCount(WORKSPACE_ID), { + wrapper: wrapper(queryClient), + }); + expect(result.current).toBe(2); + }); + + it('deduplicates conversations cached under multiple filter combinations', async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', WORKSPACE_ID, 'conversations', 'list', { keyword: '' }], { + pages: [{ data: [unreadConversation('a')], nextCursor: null }], + pageParams: [undefined], + }); + queryClient.setQueryData( + ['ws', WORKSPACE_ID, 'conversations', 'list', { status: ['unread'] }], + { + pages: [{ data: [unreadConversation('a')], nextCursor: null }], + pageParams: [undefined], + }, + ); + + const { result } = await renderHook(() => useInboxUnreadCount(WORKSPACE_ID), { + wrapper: wrapper(queryClient), + }); + expect(result.current).toBe(1); + }); + + it('ignores conversations cached under a different workspace', async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', 'other-ws', 'conversations', 'list', {}], { + pages: [{ data: [unreadConversation('a')], nextCursor: null }], + pageParams: [undefined], + }); + + const { result } = await renderHook(() => useInboxUnreadCount(WORKSPACE_ID), { + wrapper: wrapper(queryClient), + }); + expect(result.current).toBe(0); + }); + + it('recomputes when the cache changes', async () => { + const queryClient = createTestQueryClient(); + const key = ['ws', WORKSPACE_ID, 'conversations', 'list', {}]; + queryClient.setQueryData(key, { + pages: [{ data: [readConversation('a')], nextCursor: null }], + pageParams: [undefined], + }); + + const { result } = await renderHook(() => useInboxUnreadCount(WORKSPACE_ID), { + wrapper: wrapper(queryClient), + }); + expect(result.current).toBe(0); + + await act(async () => { + queryClient.setQueryData(key, { + pages: [{ data: [unreadConversation('a')], nextCursor: null }], + pageParams: [undefined], + }); + }); + + await waitFor(() => expect(result.current).toBe(1)); + }); + + it('returns 0 for a null workspaceId', async () => { + const queryClient = createTestQueryClient(); + const { result } = await renderHook(() => useInboxUnreadCount(null), { + wrapper: wrapper(queryClient), + }); + await waitFor(() => expect(result.current).toBe(0)); + }); +}); diff --git a/src/features/conversations/api/use-inbox-unread-count.ts b/src/features/conversations/api/use-inbox-unread-count.ts new file mode 100644 index 0000000..66aaa1b --- /dev/null +++ b/src/features/conversations/api/use-inbox-unread-count.ts @@ -0,0 +1,67 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useSyncExternalStore } from 'react'; + +import { isUnread } from '@/features/conversations/lib/conversation-status'; +import type { + ConversationListItem, + ListConversationsResponse, +} from '@/features/conversations/api/use-conversations-infinite'; + +/** + * Counts unread conversations across every currently CACHED conversations-list page for the given + * workspace, deduplicated by conversation id (the same conversation can appear in more than one + * cached filter combination — e.g. "All" and "Unread" — and must only count once). + * + * LIMITATION: this only sees pages TanStack Query already has in cache (whatever's been scrolled + * into view / fetched this session) — it is NOT a true server-side unread total. A conversation + * that's unread but was never fetched into any cached list page (e.g. page 3 the user hasn't + * scrolled to yet) is invisible to this count. There is no unread-count endpoint on the backend + * yet — this is the best available approximation until one exists. + * `Badge`'s own count-mode caps display at "99+", so no separate capping is needed here. + */ +function countUnreadFromCache( + queryClient: ReturnType<typeof useQueryClient>, + workspaceId: string, +): number { + if (!workspaceId) return 0; + + const seen = new Map<string, ConversationListItem>(); + + queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .forEach((query) => { + const data = query.state.data as { pages: ListConversationsResponse[] } | undefined; + data?.pages.forEach((page) => { + page.data.forEach((item) => { + seen.set(item.id, item); + }); + }); + }); + + let count = 0; + seen.forEach((conversation) => { + if (isUnread(conversation)) count += 1; + }); + return count; +} + +/** Subscribes to the query cache and recomputes the unread count on every cache change — used for + * the Inbox tab's `tabBarBadge`. `useSyncExternalStore` (rather than a `useQuery`/`useEffect` + * combo) is the correct primitive here since the "data" being read isn't a query result itself, + * it's a derived aggregate over however many list queries currently happen to be cached. */ +export function useInboxUnreadCount(workspaceId: string | null): number { + const queryClient = useQueryClient(); + + return useSyncExternalStore( + (onStoreChange) => queryClient.getQueryCache().subscribe(onStoreChange), + () => countUnreadFromCache(queryClient, workspaceId ?? ''), + () => 0, + ); +} diff --git a/src/features/conversations/components/assignment-sheet.tsx b/src/features/conversations/components/assignment-sheet.tsx new file mode 100644 index 0000000..f365990 --- /dev/null +++ b/src/features/conversations/components/assignment-sheet.tsx @@ -0,0 +1,61 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import type { RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Avatar } from '@/components/ui/avatar'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ListItem } from '@/components/ui/list-item'; +import { Sheet } from '@/components/ui/sheet'; +import { useWorkspaceMembersList } from '@/features/permissions/use-permissions'; + +interface AssignmentSheetProps { + sheetRef: RefObject<BottomSheet | null>; + workspaceId: string; + onAssign: (userId: string | null) => void; +} + +export function AssignmentSheet({ sheetRef, workspaceId, onAssign }: AssignmentSheetProps) { + const { t } = useTranslation(); + const { data: members } = useWorkspaceMembersList(workspaceId); + + return ( + <Sheet ref={sheetRef} snapPoints={['50%', '80%']}> + <BottomSheetFlatList + data={members ?? []} + keyExtractor={(member) => member.id} + ListHeaderComponent={ + <ListItem + title={t('conversations.unassign')} + onPress={() => { + onAssign(null); + sheetRef.current?.close(); + }} + /> + } + ListEmptyComponent={ + <EmptyState + title={t('settings.noMembers')} + description={t('conversations.noOtherMembers')} + /> + } + renderItem={({ item }) => ( + <ListItem + title={item.user.name ?? t('conversations.unnamed')} + leading={ + <Avatar + uri={item.user.image} + name={item.user.name ?? t('conversations.unnamed')} + size={36} + /> + } + onPress={() => { + onAssign(item.userId); + sheetRef.current?.close(); + }} + /> + )} + /> + </Sheet> + ); +} diff --git a/src/features/conversations/components/channel-badge.tsx b/src/features/conversations/components/channel-badge.tsx new file mode 100644 index 0000000..1897b96 --- /dev/null +++ b/src/features/conversations/components/channel-badge.tsx @@ -0,0 +1,46 @@ +import { StyleSheet, View } from 'react-native'; + +import { Icon, type IconName } from '@/components/ui/icon'; +import { useTheme } from '@/theme/use-theme'; + +/** Every contactInbox `channel` string the backend can send (see schema.ts `contactInboxes[].channel` + * and the list-filter `channel` enum) — wider than `colors.channel`'s keys, so unmapped channels + * fall back to a neutral color instead of crashing on an undefined lookup. */ +const CHANNEL_ICONS: Record<string, IconName> = { + messenger: 'logo-facebook', + instagram: 'logo-instagram', + whatsapp: 'logo-whatsapp', + webchat: 'chatbubble', + email: 'mail', + smtp: 'mail', + sms: 'chatbox', + zalo: 'chatbubbles', + telegram: 'paper-plane', + tiktok: 'logo-tiktok', + omnichannel: 'apps', +}; + +interface ChannelBadgeProps { + channel: string; + size?: number; +} + +export function ChannelBadge({ channel, size = 16 }: ChannelBadgeProps) { + const { colors } = useTheme(); + const color = (colors.channel as Record<string, string>)[channel] ?? colors.textSecondary; + const icon = CHANNEL_ICONS[channel] ?? 'ellipse'; + + return ( + <View style={[styles.container, { backgroundColor: color, width: size + 6, height: size + 6 }]}> + <Icon name={icon} size={size - 4} color={colors.onBrand} /> + </View> + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/src/features/conversations/components/conversation-row.test.tsx b/src/features/conversations/components/conversation-row.test.tsx new file mode 100644 index 0000000..b24decb --- /dev/null +++ b/src/features/conversations/components/conversation-row.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { initI18n } from '@/i18n'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; + +import { ConversationRow } from './conversation-row'; + +beforeAll(() => { + initI18n(); +}); + +function fakeConversation(overrides: Partial<ConversationListItem> = {}): ConversationListItem { + return { + id: 'conv-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: false, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: '2026-01-01T00:00:00.000Z', + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: 'ws-1', + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [{ channel: 'whatsapp' }], + messages: [{ text: 'Hello there', attachmentCount: 0 }], + contact: { fullName: 'Jane Doe', avatar: null, blockedAt: null }, + assignedUser: null, + assignedInboxTeam: null, + ...overrides, + } as unknown as ConversationListItem; +} + +describe('ConversationRow', () => { + it('renders the contact name and message preview', async () => { + await render(<ConversationRow conversation={fakeConversation()} onPress={() => {}} />); + + expect(screen.getByText('Jane Doe')).toBeTruthy(); + expect(screen.getByText('Hello there')).toBeTruthy(); + }); + + it('falls back to a translated "Unknown contact" label when the contact has no name', async () => { + await render( + <ConversationRow conversation={fakeConversation({ contact: null })} onPress={() => {}} />, + ); + + expect(screen.getByText('Unknown contact')).toBeTruthy(); + }); + + it('renders a translated attachment preview when the last message has no text', async () => { + await render( + <ConversationRow + conversation={fakeConversation({ + messages: [ + { text: null, attachmentCount: 3 }, + ] as unknown as ConversationListItem['messages'], + })} + onPress={() => {}} + />, + ); + + expect(screen.getByText('📎 3 attachment(s)')).toBeTruthy(); + }); + + it('calls onPress when the row is tapped', async () => { + const onPress = jest.fn(); + await render(<ConversationRow conversation={fakeConversation()} onPress={onPress} />); + + fireEvent.press(screen.getByRole('button')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('exposes accessibilityActions mirroring the wired swipe callbacks', async () => { + const onToggleRead = jest.fn(); + const onToggleBot = jest.fn(); + const onArchive = jest.fn(); + await render( + <ConversationRow + conversation={fakeConversation()} + onPress={() => {}} + onToggleRead={onToggleRead} + onToggleBot={onToggleBot} + onArchive={onArchive} + />, + ); + + const button = screen.getByLabelText(/Jane Doe/); + const actionNames = (button.props.accessibilityActions as { name: string }[]).map( + (action) => action.name, + ); + expect(actionNames).toEqual(['toggleRead', 'toggleBot', 'archive']); + + fireEvent(button, 'accessibilityAction', { nativeEvent: { actionName: 'archive' } }); + expect(onArchive).toHaveBeenCalledTimes(1); + }); + + it('omits accessibilityActions that have no wired callback', async () => { + await render(<ConversationRow conversation={fakeConversation()} onPress={() => {}} />); + + const button = screen.getByLabelText(/Jane Doe/); + expect(button.props.accessibilityActions).toEqual([]); + }); +}); diff --git a/src/features/conversations/components/conversation-row.tsx b/src/features/conversations/components/conversation-row.tsx new file mode 100644 index 0000000..f5a4edd --- /dev/null +++ b/src/features/conversations/components/conversation-row.tsx @@ -0,0 +1,298 @@ +import { memo } from 'react'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Avatar } from '@/components/ui/avatar'; +import { PressableScale } from '@/components/ui/pressable-scale'; +import { RelativeTime } from '@/components/ui/relative-time'; +import type { SwipeAction } from '@/components/ui/swipeable-row'; +import { SwipeableRow } from '@/components/ui/swipeable-row'; +import { Text } from '@/components/ui/text'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; +import { isBotActive, isUnread } from '@/features/conversations/lib/conversation-status'; +import { withAlpha } from '@/theme/color-utils'; +import { useTheme } from '@/theme/use-theme'; + +import { ChannelBadge } from './channel-badge'; +import type { ConversationStatusChip } from './tag-chips'; +import { TagChips } from './tag-chips'; + +interface ConversationRowProps { + conversation: ConversationListItem; + onPress: () => void; + onLongPress?: () => void; + onToggleRead?: () => void; + onToggleBot?: () => void; + onArchive?: () => void; +} + +function lastMessagePreview(conversation: ConversationListItem, t: TFunction): string { + const lastMessage = conversation.messages[conversation.messages.length - 1]; + if (!lastMessage) return ''; + if (lastMessage.text) return lastMessage.text; + if (lastMessage.attachmentCount) { + return t('conversations.attachmentPreview', { count: lastMessage.attachmentCount }); + } + return ''; +} + +function statusChips(conversation: ConversationListItem): ConversationStatusChip[] { + const chips: ConversationStatusChip[] = []; + if (conversation.followed) chips.push('followUp'); + if (conversation.archivedAt) chips.push('archived'); + if (conversation.contact?.blockedAt) chips.push('blocked'); + return chips; +} + +/** + * Presentational conversation row: renders from `conversation` and calls back to the parent + * screen for every action — it never calls a mutation hook directly. `onToggleRead`/`onToggleBot`/ + * `onArchive` are optional so this component still renders sensibly in contexts that don't wire + * swipe actions (e.g. a future preview/gallery use). + * + * Swipe: start (left in LTR) reveals a read/unread toggle; end (right in LTR) reveals bot toggle + + * archive. `SwipeableRow` itself mirrors these for RTL. Archive does not confirm inline — the + * screen is expected to show an "Archived — Undo" toast (toast+undo is less friction than a modal + * confirm and is what `onArchive` is wired to in conversations/index.tsx). + */ +export const ConversationRow = memo(function ConversationRow({ + conversation, + onPress, + onLongPress, + onToggleRead, + onToggleBot, + onArchive, +}: ConversationRowProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const contactName = conversation.contact?.fullName ?? t('conversations.unknownContact'); + const channel = conversation.contactInboxes[0]?.channel ?? 'omnichannel'; + const preview = lastMessagePreview(conversation, t); + const unread = isUnread(conversation); + const botActive = isBotActive(conversation.botEnabled, conversation.botResumeAt); + const botPaused = !conversation.botEnabled && !botActive; + const assigneeName = conversation.assignedUser?.name; + const relativeTimeLabel = conversation.lastActivityAt + ? new Date(conversation.lastActivityAt).toLocaleString() + : ''; + + const chips = statusChips(conversation); + + const accessibilityLabel = [ + contactName, + channel, + unread ? t('conversations.a11yUnread') : null, + preview || null, + relativeTimeLabel || null, + ] + .filter(Boolean) + .join(', '); + + const leftActions: SwipeAction[] | undefined = onToggleRead + ? [ + { + icon: unread ? 'mail-open-outline' : 'mail-unread-outline', + label: unread ? t('conversations.markRead') : t('conversations.markUnread'), + color: colors.brand, + onPress: onToggleRead, + }, + ] + : undefined; + + const rightActions: SwipeAction[] = []; + if (onToggleBot) { + rightActions.push({ + icon: botActive ? 'sparkles' : 'sparkles-outline', + label: botActive ? t('conversations.disableBot') : t('conversations.enableBot'), + color: colors.bubbleBotAccent, + onPress: onToggleBot, + }); + } + if (onArchive) { + rightActions.push({ + icon: 'archive-outline', + label: t('conversations.archive'), + color: colors.textSecondary, + destructive: true, + onPress: onArchive, + }); + } + + const accessibilityActions = [ + ...(onToggleRead + ? [ + { + name: 'toggleRead', + label: unread ? t('conversations.markRead') : t('conversations.markUnread'), + }, + ] + : []), + ...(onToggleBot + ? [ + { + name: 'toggleBot', + label: botActive ? t('conversations.disableBot') : t('conversations.enableBot'), + }, + ] + : []), + ...(onArchive ? [{ name: 'archive', label: t('conversations.archive') }] : []), + ]; + + function handleAccessibilityAction(event: { nativeEvent: { actionName: string } }) { + switch (event.nativeEvent.actionName) { + case 'toggleRead': + onToggleRead?.(); + return; + case 'toggleBot': + onToggleBot?.(); + return; + case 'archive': + onArchive?.(); + return; + } + } + + return ( + <SwipeableRow leftActions={leftActions} rightActions={rightActions}> + <PressableScale + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityActions={accessibilityActions} + onAccessibilityAction={handleAccessibilityAction} + onPress={onPress} + onLongPress={onLongPress} + haptic={false} + style={[ + styles.row, + { + paddingVertical: spacing.ms, + paddingHorizontal: spacing.md, + gap: spacing.ms, + backgroundColor: colors.surface1, + }, + ]} + > + <View style={styles.avatarWrap}> + <Avatar + uri={conversation.contact?.avatar} + name={contactName} + size={52} + ring={botActive ? 'bot' : 'none'} + badge={<ChannelBadge channel={channel} size={14} />} + /> + </View> + + <View style={styles.body}> + <View style={styles.headerRow}> + <Text variant={unread ? 'bodyStrong' : 'body'} numberOfLines={1} style={styles.name}> + {contactName} + </Text> + {conversation.lastActivityAt ? ( + <RelativeTime date={conversation.lastActivityAt} /> + ) : null} + </View> + <View style={styles.previewRow}> + <Text + variant="callout" + color={unread ? 'primary' : 'secondary'} + numberOfLines={1} + style={[styles.preview, unread && styles.previewUnread]} + > + {preview} + </Text> + {unread ? <View style={[styles.unreadDot, { backgroundColor: colors.brand }]} /> : null} + </View> + + <View style={[styles.metaRow, { gap: spacing.xs }]}> + {botActive || botPaused ? ( + <View + style={[ + styles.metaChip, + { + backgroundColor: botPaused + ? colors.warningSoft + : withAlpha(colors.bubbleBotAccent, 0.16), + }, + ]} + > + <Text + variant="micro" + style={{ + color: botPaused ? colors.warning : colors.bubbleBotAccent, + fontWeight: '600', + }} + > + {botPaused ? t('conversations.botPaused') : t('conversations.botActive')} + </Text> + </View> + ) : null} + {assigneeName ? ( + <Text variant="micro" color="tertiary" numberOfLines={1}> + {t('conversations.assignedTo', { name: assigneeName })} + </Text> + ) : null} + </View> + + <TagChips chips={chips} /> + </View> + </PressableScale> + </SwipeableRow> + ); +}); + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + avatarWrap: { + position: 'relative', + }, + body: { + flex: 1, + gap: 3, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, + }, + name: { + flex: 1, + }, + previewRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + preview: { + flex: 1, + }, + previewUnread: { + fontWeight: '600', + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, + metaChip: { + borderRadius: 999, + paddingHorizontal: 6, + paddingVertical: 2, + }, +}); + +/** FlashList `getItemType` resolver — every row renders the same shape today, so a single + * constant type is correct; kept as a named export so the parent screen's FlashList can wire it + * without guessing at a magic string. */ +export function conversationRowItemType(): string { + return 'conversation-row'; +} diff --git a/src/features/conversations/components/filter-chips-row.test.tsx b/src/features/conversations/components/filter-chips-row.test.tsx new file mode 100644 index 0000000..532528b --- /dev/null +++ b/src/features/conversations/components/filter-chips-row.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; + +import { initI18n } from '@/i18n'; + +import { FilterChipsRow } from './filter-chips-row'; + +beforeAll(() => { + initI18n(); +}); + +describe('FilterChipsRow', () => { + it('renders every quick-filter chip with "All" selected by default', async () => { + await render(<FilterChipsRow />); + + for (const label of ['All', 'Unread', 'Mine', 'Bot handling', 'Following']) { + expect(screen.getByText(label)).toBeTruthy(); + } + expect(screen.getByRole('button', { name: 'All' }).props.accessibilityState).toMatchObject({ + selected: true, + }); + }); + + it('does not flex-grow, so it hugs its content instead of splitting the screen with the list', async () => { + // RN's ScrollView base style is `flexGrow: 1`; inside the Inbox flex column that made the chips + // row take half the screen (regression seen on device). + await render(<FilterChipsRow />); + + const style = StyleSheet.flatten(screen.getByTestId('filter-chips-row').props.style); + expect(style.flexGrow).toBe(0); + }); +}); diff --git a/src/features/conversations/components/filter-chips-row.tsx b/src/features/conversations/components/filter-chips-row.tsx new file mode 100644 index 0000000..c27619b --- /dev/null +++ b/src/features/conversations/components/filter-chips-row.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from 'react-i18next'; +import { ScrollView, StyleSheet } from 'react-native'; + +import { Chip } from '@/components/ui/chip'; +import { useConversationFilters } from '@/features/conversations/stores/use-conversation-filters'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Quick-filter chip: All / Unread / Mine / Bot / Following. These are single-select shortcuts + * layered on top of the same store the full filter sheet reads (`use-conversation-filters.ts`) — + * there's no separate "quick filter" concept on the backend, so each chip just sets the store + * fields that already exist: + * - Unread → `status: ['unread']` (a real server-side status filter) + * - Following → `status: ['followUp']` (ditto) + * - Mine → `assignedId: currentUserId` (ditto) + * - Bot → `botCategory: 'bot'` (ditto) + * - All → clears all four quick-filter dimensions (channel/keyword from the sheet/search bar + * are left untouched — "All" only resets the facets this row itself can set). + * + * Only one quick filter is active at a time (chips are mutually exclusive here, matching the + * "quick shortcut" mental model) — picking a second chip replaces whichever one was active, + * rather than combining them. The full filter sheet still supports combining `status` values + * freely; this row is intentionally simpler. + */ +type QuickFilter = 'all' | 'unread' | 'mine' | 'bot' | 'following'; + +export function FilterChipsRow() { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const currentUserId = useAuthStore((state) => state.user?.id); + + const status = useConversationFilters((state) => state.status); + const assignedId = useConversationFilters((state) => state.assignedId); + const botCategory = useConversationFilters((state) => state.botCategory); + const setStatus = useConversationFilters((state) => state.setStatus); + const setAssignedId = useConversationFilters((state) => state.setAssignedId); + const setBotCategory = useConversationFilters((state) => state.setBotCategory); + + const active: QuickFilter = (() => { + if (status?.includes('unread')) return 'unread'; + if (status?.includes('followUp')) return 'following'; + if (botCategory === 'bot') return 'bot'; + if (currentUserId && assignedId === currentUserId) return 'mine'; + return 'all'; + })(); + + function selectQuickFilter(next: QuickFilter) { + // Clear the other quick-filter dimensions first so only one is ever active. + setStatus(undefined); + setAssignedId(undefined); + setBotCategory(undefined); + + switch (next) { + case 'unread': + setStatus(['unread']); + return; + case 'following': + setStatus(['followUp']); + return; + case 'bot': + setBotCategory('bot'); + return; + case 'mine': + if (currentUserId) setAssignedId(currentUserId); + return; + case 'all': + return; + } + } + + const chips: { value: QuickFilter; label: string }[] = [ + { value: 'all', label: t('conversations.filterAll') }, + { value: 'unread', label: t('conversations.statusUnread') }, + { value: 'mine', label: t('conversations.quickFilterMine') }, + { value: 'bot', label: t('conversations.handledByBot') }, + { value: 'following', label: t('conversations.statusFollowUp') }, + ]; + + return ( + <ScrollView + testID="filter-chips-row" + horizontal + showsHorizontalScrollIndicator={false} + style={styles.scroll} + contentContainerStyle={[ + styles.row, + { paddingHorizontal: spacing.md, paddingBottom: spacing.sm, gap: spacing.xs }, + ]} + > + {chips.map((chip) => ( + <Chip + key={chip.value} + label={chip.label} + selected={active === chip.value} + tone="brand" + onPress={() => selectQuickFilter(chip.value)} + /> + ))} + </ScrollView> + ); +} + +const styles = StyleSheet.create({ + // ScrollView's base style is `flexGrow: 1`; in the Inbox flex column that made this row split the + // remaining height 50/50 with the conversation FlashList. Hug the chips' own height instead. + scroll: { + flexGrow: 0, + flexShrink: 0, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + }, +}); diff --git a/src/features/conversations/components/filter-sheet.tsx b/src/features/conversations/components/filter-sheet.tsx new file mode 100644 index 0000000..4fa5ec9 --- /dev/null +++ b/src/features/conversations/components/filter-sheet.tsx @@ -0,0 +1,219 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import type { RefObject } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { SegmentedTabs } from '@/components/ui/segmented-tabs'; +import { Sheet } from '@/components/ui/sheet'; +import { Text } from '@/components/ui/text'; +import type { + BotCategoryFilter, + ConversationChannelFilter, + ConversationStatusFilter, +} from '@/features/conversations/stores/use-conversation-filters'; +import { useConversationFilters } from '@/features/conversations/stores/use-conversation-filters'; +import { useTheme } from '@/theme/use-theme'; + +/** All 9 channel enum values the backend accepts for this filter (see + * `use-conversation-filters.ts`'s `ConversationChannelFilter`), plus a leading "all" — previously + * truncated to the first 4 via `.slice(0, 4)`, silently hiding zalo/telegram/smtp/tiktok/ + * omnichannel from the filter sheet. Channel/platform brand names (Messenger, Instagram, + * WhatsApp, Zalo, Telegram, TikTok) stay literal — they're proper nouns, not translatable UI + * copy. "Webchat", "Email", and "Omnichannel" are ours. Rendered as a wrapping chip row rather + * than `SegmentedTabs` — 10 options don't fit a single-row segmented control. */ +const CHANNEL_OPTIONS: { value: ConversationChannelFilter | 'all'; labelKey: string }[] = [ + { value: 'all', labelKey: 'conversations.filterAll' }, + { value: 'omnichannel', labelKey: 'conversations.channelOmnichannel' }, + { value: 'messenger', labelKey: 'conversations.channelMessenger' }, + { value: 'instagram', labelKey: 'conversations.channelInstagram' }, + { value: 'whatsapp', labelKey: 'conversations.channelWhatsapp' }, + { value: 'webchat', labelKey: 'conversations.channelWebchat' }, + { value: 'zalo', labelKey: 'conversations.channelZalo' }, + { value: 'telegram', labelKey: 'conversations.channelTelegram' }, + { value: 'smtp', labelKey: 'conversations.channelEmail' }, + { value: 'tiktok', labelKey: 'conversations.channelTiktok' }, +]; + +const STATUS_OPTIONS: { value: ConversationStatusFilter; labelKey: string }[] = [ + { value: 'unread', labelKey: 'conversations.statusUnread' }, + { value: 'noAdminReply', labelKey: 'conversations.statusNoAdminReply' }, + { value: 'followUp', labelKey: 'conversations.statusFollowUp' }, + { value: 'archived', labelKey: 'conversations.statusArchived' }, + { value: 'blocked', labelKey: 'conversations.statusBlocked' }, +]; + +const BOT_CATEGORY_OPTIONS: { value: BotCategoryFilter; labelKey: string }[] = [ + { value: 'all', labelKey: 'conversations.filterAll' }, + { value: 'bot', labelKey: 'conversations.handledByBot' }, + { value: 'human', labelKey: 'conversations.handledByHuman' }, +]; + +type BotEnabledOption = 'all' | 'on' | 'off'; + +const BOT_ENABLED_OPTIONS: { value: BotEnabledOption; labelKey: string }[] = [ + { value: 'all', labelKey: 'conversations.filterAll' }, + { value: 'on', labelKey: 'conversations.botOn' }, + { value: 'off', labelKey: 'conversations.botOff' }, +]; + +interface FilterSheetProps { + sheetRef: RefObject<BottomSheet | null>; +} + +/** Filter sheet for the conversations list — form controls map 1:1 to the top-level fields + * `listConversationsByPOSTAuthenticatedAPI` accepts (see use-conversation-filters.ts). The + * advanced `contactFilter` condition-builder from that schema is not exposed here — it's a + * large audience-builder DSL that belongs to a dedicated "advanced search" surface, not this + * quick filter sheet. */ +export function FilterSheet({ sheetRef }: FilterSheetProps) { + const { t } = useTranslation(); + const { spacing } = useTheme(); + const channel = useConversationFilters((state) => state.channel); + const setChannel = useConversationFilters((state) => state.setChannel); + const status = useConversationFilters((state) => state.status); + const setStatus = useConversationFilters((state) => state.setStatus); + const botCategory = useConversationFilters((state) => state.botCategory); + const setBotCategory = useConversationFilters((state) => state.setBotCategory); + const botEnabled = useConversationFilters((state) => state.botEnabled); + const setBotEnabled = useConversationFilters((state) => state.setBotEnabled); + const reset = useConversationFilters((state) => state.reset); + + function toggleStatus(value: ConversationStatusFilter) { + const current = status ?? []; + const next = current.includes(value) ? current.filter((s) => s !== value) : [...current, value]; + setStatus(next.length > 0 ? next : undefined); + } + + const botEnabledValue: BotEnabledOption = + botEnabled === true ? 'on' : botEnabled === false ? 'off' : 'all'; + + function handleBotEnabledChange(value: BotEnabledOption) { + setBotEnabled(value === 'all' ? undefined : value === 'on'); + } + + return ( + <Sheet ref={sheetRef} snapPoints={['60%', '90%']}> + <BottomSheetScrollView contentContainerStyle={{ padding: spacing.md, gap: spacing.md }}> + <Text variant="title">{t('conversations.filters')}</Text> + + <View style={styles.section}> + <Text variant="caption" color="secondary"> + {t('conversations.channel')} + </Text> + <View style={styles.chipRow}> + {CHANNEL_OPTIONS.map((option) => { + const active = (channel ?? 'all') === option.value; + return ( + <StatusChip + key={option.value} + label={t(option.labelKey)} + active={active} + onPress={() => setChannel(option.value === 'all' ? undefined : option.value)} + /> + ); + })} + </View> + </View> + + <View style={styles.section}> + <Text variant="caption" color="secondary"> + {t('conversations.handledBy')} + </Text> + <SegmentedTabs + options={BOT_CATEGORY_OPTIONS.map((option) => ({ + value: option.value, + label: t(option.labelKey), + }))} + value={botCategory ?? 'all'} + onChange={(value) => setBotCategory(value === 'all' ? undefined : value)} + /> + </View> + + <View style={styles.section}> + <Text variant="caption" color="secondary"> + {t('conversations.botOnOffLabel')} + </Text> + <SegmentedTabs + options={BOT_ENABLED_OPTIONS.map((option) => ({ + value: option.value, + label: t(option.labelKey), + }))} + value={botEnabledValue} + onChange={handleBotEnabledChange} + /> + </View> + + <View style={styles.section}> + <Text variant="caption" color="secondary"> + {t('conversations.status')} + </Text> + <View style={styles.chipRow}> + {STATUS_OPTIONS.map((option) => { + const active = (status ?? []).includes(option.value); + return ( + <StatusChip + key={option.value} + label={t(option.labelKey)} + active={active} + onPress={() => toggleStatus(option.value)} + /> + ); + })} + </View> + </View> + + <Button label={t('conversations.clearFilters')} variant="secondary" onPress={reset} /> + <Button + label={t('common.done')} + variant="primary" + onPress={() => sheetRef.current?.close()} + /> + </BottomSheetScrollView> + </Sheet> + ); +} + +function StatusChip({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + const { colors, radius, spacing } = useTheme(); + return ( + <Pressable + onPress={onPress} + style={[ + styles.statusChip, + { + backgroundColor: active ? colors.brand : colors.surface2, + borderRadius: radius.full, + paddingHorizontal: spacing.ms, + }, + ]} + > + <Text variant="caption" color={active ? 'inverse' : 'primary'}> + {label} + </Text> + </Pressable> + ); +} + +const styles = StyleSheet.create({ + section: { + gap: 8, + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + statusChip: { + paddingVertical: 6, + }, +}); diff --git a/src/features/conversations/components/tag-chips.tsx b/src/features/conversations/components/tag-chips.tsx new file mode 100644 index 0000000..93aecc9 --- /dev/null +++ b/src/features/conversations/components/tag-chips.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Badge } from '@/components/ui/badge'; +import { useTheme } from '@/theme/use-theme'; + +/** + * The conversation-list response (schema.ts) has no dedicated free-form `tags` array on the + * conversation item itself — the `status`/`tags` request filters share one enum + * (noAdminReply|unread|followUp|archived|blocked). This component renders derived status chips + * from that same vocabulary (computed by the caller from the conversation's own fields — e.g. + * `followed`, `archivedAt`, `botEnabled` — see conversation-row.tsx), not a separate tags API. + */ +export type ConversationStatusChip = + 'noAdminReply' | 'unread' | 'followUp' | 'archived' | 'blocked'; + +const CHIP_LABEL_KEYS: Record<ConversationStatusChip, string> = { + noAdminReply: 'conversations.statusNoAdminReply', + unread: 'conversations.statusUnread', + followUp: 'conversations.statusFollowUp', + archived: 'conversations.statusArchived', + blocked: 'conversations.statusBlocked', +}; + +interface TagChipsProps { + chips: ConversationStatusChip[]; +} + +export function TagChips({ chips }: TagChipsProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + + if (chips.length === 0) { + return null; + } + + return ( + <View style={styles.row}> + {chips.map((chip) => ( + <Badge + key={chip} + color={ + chip === 'blocked' ? colors.danger : chip === 'unread' ? colors.brand : colors.surface2 + } + textColor={ + chip === 'blocked' || chip === 'unread' ? colors.onBrand : colors.textSecondary + } + > + {t(CHIP_LABEL_KEYS[chip])} + </Badge> + ))} + </View> + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + gap: 4, + flexWrap: 'wrap', + }, +}); diff --git a/src/features/conversations/lib/conversation-status.test.ts b/src/features/conversations/lib/conversation-status.test.ts new file mode 100644 index 0000000..5bbdfbc --- /dev/null +++ b/src/features/conversations/lib/conversation-status.test.ts @@ -0,0 +1,63 @@ +import { isBotActive, isUnread } from './conversation-status'; + +describe('isBotActive', () => { + it('returns true when the bot is enabled, regardless of botResumeAt', () => { + expect(isBotActive(true, null)).toBe(true); + expect(isBotActive(true, new Date(Date.now() + 60_000).toISOString())).toBe(true); + }); + + it('returns false (paused) when disabled and botResumeAt is in the future', () => { + const future = new Date(Date.now() + 60_000).toISOString(); + expect(isBotActive(false, future)).toBe(false); + }); + + it('returns true (auto-resumed) when disabled and botResumeAt is in the past', () => { + const past = new Date(Date.now() - 60_000).toISOString(); + expect(isBotActive(false, past)).toBe(true); + }); + + it('returns false when disabled with no botResumeAt', () => { + expect(isBotActive(false, null)).toBe(false); + }); + + it('respects an explicit `now` for deterministic testing', () => { + const resumeAt = new Date('2026-01-01T00:00:00.000Z').toISOString(); + const beforeResume = new Date('2025-12-31T00:00:00.000Z').getTime(); + const afterResume = new Date('2026-01-02T00:00:00.000Z').getTime(); + expect(isBotActive(false, resumeAt, beforeResume)).toBe(false); + expect(isBotActive(false, resumeAt, afterResume)).toBe(true); + }); +}); + +describe('isUnread', () => { + it('returns true when lastActivityAt is newer than agentLastReadAt', () => { + expect( + isUnread({ + lastActivityAt: '2026-01-02T00:00:00.000Z', + agentLastReadAt: '2026-01-01T00:00:00.000Z', + }), + ).toBe(true); + }); + + it('returns false when agentLastReadAt is newer than or equal to lastActivityAt', () => { + expect( + isUnread({ + lastActivityAt: '2026-01-01T00:00:00.000Z', + agentLastReadAt: '2026-01-02T00:00:00.000Z', + }), + ).toBe(false); + }); + + it('returns true when there is activity but agentLastReadAt is null', () => { + expect(isUnread({ lastActivityAt: '2026-01-01T00:00:00.000Z', agentLastReadAt: null })).toBe( + true, + ); + }); + + it('returns false when there is no lastActivityAt at all', () => { + expect(isUnread({ lastActivityAt: null, agentLastReadAt: null })).toBe(false); + expect(isUnread({ lastActivityAt: null, agentLastReadAt: '2026-01-01T00:00:00.000Z' })).toBe( + false, + ); + }); +}); diff --git a/src/features/conversations/lib/conversation-status.ts b/src/features/conversations/lib/conversation-status.ts new file mode 100644 index 0000000..0da3eb8 --- /dev/null +++ b/src/features/conversations/lib/conversation-status.ts @@ -0,0 +1,32 @@ +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; + +/** + * Bot-active derivation, matching the backend's own rule (`isBotActive = + * botEnabled || botResumeAt < now`): the bot is considered "active" either because it's + * explicitly enabled, or because a previous 24h disable-pause (`botResumeAt`) has already + * elapsed — the backend auto-resumes the bot at that point even though `botEnabled` itself only + * flips back via the next `enable-bot` call or scheduled job. A `botResumeAt` in the future means + * the bot is still paused. + */ +export function isBotActive( + botEnabled: boolean, + botResumeAt: string | null, + now: number = Date.now(), +): boolean { + if (botEnabled) return true; + if (!botResumeAt) return false; + return new Date(botResumeAt).getTime() < now; +} + +/** Unread = the contact's last incoming activity is newer than the agent's last read timestamp. + * There's no direct `unreadCount` field on the schema — this derives a boolean from the two + * timestamps the response does carry (`lastActivityAt`, `agentLastReadAt`). Extracted verbatim + * from the previous inline `isUnread` in conversation-row.tsx — same semantics, same edge cases + * (no activity yet → read; activity but never read → unread). */ +export function isUnread( + conversation: Pick<ConversationListItem, 'lastActivityAt' | 'agentLastReadAt'>, +): boolean { + if (!conversation.lastActivityAt) return false; + if (!conversation.agentLastReadAt) return true; + return new Date(conversation.lastActivityAt) > new Date(conversation.agentLastReadAt); +} diff --git a/src/features/conversations/lib/find-conversation-in-cache.test.ts b/src/features/conversations/lib/find-conversation-in-cache.test.ts new file mode 100644 index 0000000..98a7dca --- /dev/null +++ b/src/features/conversations/lib/find-conversation-in-cache.test.ts @@ -0,0 +1,113 @@ +import { QueryClient } from '@tanstack/react-query'; + +import type { + ConversationListItem, + ListConversationsResponse, +} from '@/features/conversations/api/use-conversations-infinite'; + +import { findConversationInListCache } from './find-conversation-in-cache'; + +function fakeConversation(id: string): ConversationListItem { + return { + id, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: true, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: null, + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: 'ws-1', + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [], + messages: [], + contact: null, + assignedUser: null, + assignedInboxTeam: null, + } as unknown as ConversationListItem; +} + +function fakePage(items: ConversationListItem[]): ListConversationsResponse { + return { data: items, nextCursor: null, prevCursor: null }; +} + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +afterEach(() => { + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } +}); + +describe('findConversationInListCache', () => { + it('finds a conversation cached in a list query page for the given workspace', () => { + const queryClient = createTestQueryClient(); + const target = fakeConversation('conv-1'); + queryClient.setQueryData(['ws', 'ws-1', 'conversations', 'list', { keyword: '' }], { + pages: [fakePage([fakeConversation('conv-0'), target])], + pageParams: [undefined], + }); + + const found = findConversationInListCache(queryClient, 'ws-1', 'conv-1'); + + expect(found?.id).toBe('conv-1'); + }); + + it('searches across every cached list query (different filter combinations)', () => { + const queryClient = createTestQueryClient(); + const target = fakeConversation('conv-2'); + queryClient.setQueryData(['ws', 'ws-1', 'conversations', 'list', { keyword: '' }], { + pages: [fakePage([fakeConversation('conv-0')])], + pageParams: [undefined], + }); + queryClient.setQueryData(['ws', 'ws-1', 'conversations', 'list', { status: ['unread'] }], { + pages: [fakePage([target])], + pageParams: [undefined], + }); + + const found = findConversationInListCache(queryClient, 'ws-1', 'conv-2'); + + expect(found?.id).toBe('conv-2'); + }); + + it('returns undefined when the conversation is not cached', () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', 'ws-1', 'conversations', 'list', {}], { + pages: [fakePage([fakeConversation('conv-0')])], + pageParams: [undefined], + }); + + expect(findConversationInListCache(queryClient, 'ws-1', 'missing')).toBeUndefined(); + }); + + it('does not match conversations cached under a different workspace', () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(['ws', 'ws-other', 'conversations', 'list', {}], { + pages: [fakePage([fakeConversation('conv-1')])], + pageParams: [undefined], + }); + + expect(findConversationInListCache(queryClient, 'ws-1', 'conv-1')).toBeUndefined(); + }); + + it('returns undefined when no list queries are cached at all', () => { + const queryClient = createTestQueryClient(); + expect(findConversationInListCache(queryClient, 'ws-1', 'conv-1')).toBeUndefined(); + }); +}); diff --git a/src/features/conversations/lib/find-conversation-in-cache.ts b/src/features/conversations/lib/find-conversation-in-cache.ts new file mode 100644 index 0000000..f552f29 --- /dev/null +++ b/src/features/conversations/lib/find-conversation-in-cache.ts @@ -0,0 +1,37 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import type { + ConversationListItem, + ListConversationsResponse, +} from '@/features/conversations/api/use-conversations-infinite'; + +/** + * Scans every cached conversations-list infinite query (any filter combination) for a row + * matching `conversationId`. Extracted from the chat screen's previous inline cache scan so it can + * be reused as `placeholderData` for a conversation-detail query without duplicating the + * predicate/flatMap logic — both `[conversationId]/index.tsx` (mark-read guard) and + * `chat-header.tsx` (identity placeholder) call this directly today. + * + * Conversation header/preview data in this app generally comes from whichever list page the user + * navigated from, rather than a dedicated detail fetch — this is the reusable form of that lookup. + */ +export function findConversationInListCache( + queryClient: QueryClient, + workspaceId: string, + conversationId: string, +): ConversationListItem | undefined { + return queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .flatMap((query) => { + const data = query.state.data as { pages: ListConversationsResponse[] } | undefined; + return data?.pages.flatMap((page) => page.data) ?? []; + }) + .find((item) => item.id === conversationId); +} diff --git a/src/features/conversations/stores/use-conversation-filters.ts b/src/features/conversations/stores/use-conversation-filters.ts new file mode 100644 index 0000000..9f273ec --- /dev/null +++ b/src/features/conversations/stores/use-conversation-filters.ts @@ -0,0 +1,85 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +/** Channel enum values from `conversationsAPI.listConversationsByPOSTAuthenticatedAPI` (generated + * schema.ts) — the single-channel filter field, not the per-conversation contactInbox channel. */ +export type ConversationChannelFilter = + | 'omnichannel' + | 'webchat' + | 'messenger' + | 'whatsapp' + | 'zalo' + | 'smtp' + | 'telegram' + | 'instagram' + | 'tiktok'; + +/** Status enum values — reused by the schema for both `status` and `tags` request fields. */ +export type ConversationStatusFilter = + 'noAdminReply' | 'unread' | 'followUp' | 'archived' | 'blocked'; + +export type BotCategoryFilter = 'bot' | 'human' | 'all'; + +/** Persisted conversation-list filters, matching exactly the top-level fields + * `listConversationsByPOSTAuthenticatedAPI` accepts (excluding `contactFilter`, `sort`, `cursor`, + * `perPage` — those are pagination/advanced-audience concerns, not user-facing filters here). */ +interface ConversationFiltersState { + botCategory: BotCategoryFilter | undefined; + assignedId: string | null | undefined; + channel: ConversationChannelFilter | undefined; + status: ConversationStatusFilter[] | undefined; + keyword: string; + botEnabled: boolean | null | undefined; + setBotCategory: (value: BotCategoryFilter | undefined) => void; + setAssignedId: (value: string | null | undefined) => void; + setChannel: (value: ConversationChannelFilter | undefined) => void; + setStatus: (value: ConversationStatusFilter[] | undefined) => void; + setKeyword: (value: string) => void; + setBotEnabled: (value: boolean | null | undefined) => void; + reset: () => void; +} + +const defaultFilters = { + botCategory: undefined, + assignedId: undefined, + channel: undefined, + status: undefined, + keyword: '', + botEnabled: undefined, +} as const; + +export const useConversationFilters = create<ConversationFiltersState>()( + persist( + (set) => ({ + ...defaultFilters, + setBotCategory: (botCategory) => set({ botCategory }), + setAssignedId: (assignedId) => set({ assignedId }), + setChannel: (channel) => set({ channel }), + setStatus: (status) => set({ status }), + setKeyword: (keyword) => set({ keyword }), + setBotEnabled: (botEnabled) => set({ botEnabled }), + reset: () => set(defaultFilters), + }), + { + name: 'conversation-filters-store', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); + +/** Plain-object snapshot of the active filters, safe to embed in a query key (persist's store API + * carries functions too, which don't belong in a key). Returns a new object every call, so any + * caller passing this straight to `useConversationFilters(...)` MUST wrap it in `useShallow` from + * `zustand/react/shallow` — zustand v5's useSyncExternalStore has no shallow-equality fallback + * (v4 did), so an unwrapped call re-renders on every render and trips React's infinite-loop guard. */ +export function conversationFiltersSnapshot(state: ConversationFiltersState) { + return { + botCategory: state.botCategory, + assignedId: state.assignedId, + channel: state.channel, + status: state.status, + keyword: state.keyword, + botEnabled: state.botEnabled, + }; +} diff --git a/src/features/flows/api/use-flows.ts b/src/features/flows/api/use-flows.ts new file mode 100644 index 0000000..22375fa --- /dev/null +++ b/src/features/flows/api/use-flows.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; + +/** + * `flowsAPI.privateListFlowsAPI` (`GET /workspaces/{workspaceId}/flows`) is the session-authenticated + * flows list (the `WorkspaceTokenAPI` sibling requires a workspace token, not a bearer session). + * Offset-paginated (`page`/`perPage`/`pageCount`), like contacts and members — NOT the cursor shape. + * Flow lists are typically small, so this fetches a single large page rather than driving + * useInfiniteQuery — revisit if a workspace has enough flows to need real pagination. + */ +type ListFlowsOperation = operations['flowsAPI.privateListFlowsAPI']; +export type ListFlowsResponse = ListFlowsOperation['responses'][200]['content']['application/json']; +export type FlowListItem = ListFlowsResponse['data'][number]; + +const PER_PAGE = 100; + +export function useFlows(workspaceId: string | null) { + return useQuery({ + queryKey: queryKeys.ws.flows.list(workspaceId ?? ''), + enabled: workspaceId !== null, + queryFn: async (): Promise<FlowListItem[]> => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/flows', { + params: { path: { workspaceId: workspaceId! }, query: { page: 1, perPage: PER_PAGE } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} diff --git a/src/features/permissions/use-permissions.test.tsx b/src/features/permissions/use-permissions.test.tsx new file mode 100644 index 0000000..c4ddd2a --- /dev/null +++ b/src/features/permissions/use-permissions.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import type { PropsWithChildren } from 'react'; + +import { apiClient } from '@/api/client'; +import { usePermissions } from '@/features/permissions/use-permissions'; +import { useAuthStore } from '@/stores/use-auth-store'; + +jest.mock('@/api/client', () => ({ + apiClient: { + GET: jest.fn(), + }, +})); + +const mockedGet = apiClient.GET as jest.Mock; + +const activeQueryClients: QueryClient[] = []; + +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +function renderPermissions(queryClient: QueryClient, workspaceId: string | null) { + function wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return renderHook(() => usePermissions(workspaceId), { wrapper }); +} + +function member(overrides: Partial<Record<string, unknown>> = {}) { + return { + id: 'member-1', + userId: 'user-1', + permissions: { + superAdmin: false, + analytics: true, + flows: true, + contacts: true, + onlyAssignedContacts: false, + emailAndPhone: true, + broadcast: true, + ecommerce: true, + }, + ...overrides, + }; +} + +describe('usePermissions', () => { + beforeEach(() => { + mockedGet.mockReset(); + useAuthStore.setState({ + status: 'signed-in', + user: { id: 'user-1', email: 'a@b.com', name: 'A', mustChangePassword: false }, + }); + }); + + afterEach(() => { + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } + }); + + it('fails closed on PII-masking bits while the members query is loading', async () => { + // Never resolves during this assertion window — simulates the loading state. + mockedGet.mockReturnValue(new Promise(() => {})); + const queryClient = createTestQueryClient(); + + const { result } = await renderPermissions(queryClient, 'ws-1'); + + expect(result.current.isLoading).toBe(true); + expect(result.current.canSeeEmailAndPhone).toBe(false); + expect(result.current.onlyAssignedContacts).toBe(true); + }); + + it('fails closed on PII-masking bits when the member row is not found', async () => { + mockedGet.mockResolvedValue({ data: { data: [member({ userId: 'someone-else' })] } }); + const queryClient = createTestQueryClient(); + + const { result } = await renderPermissions(queryClient, 'ws-1'); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.canSeeEmailAndPhone).toBe(false); + expect(result.current.onlyAssignedContacts).toBe(true); + }); + + it('resolves the real permission bits once the member row loads', async () => { + mockedGet.mockResolvedValue({ + data: { data: [member({ permissions: { ...member().permissions, emailAndPhone: false } })] }, + }); + const queryClient = createTestQueryClient(); + + const { result } = await renderPermissions(queryClient, 'ws-1'); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.canSeeEmailAndPhone).toBe(false); + expect(result.current.onlyAssignedContacts).toBe(false); + }); + + it('super admins bypass every restriction once loaded', async () => { + mockedGet.mockResolvedValue({ + data: { data: [member({ permissions: { ...member().permissions, superAdmin: true } })] }, + }); + const queryClient = createTestQueryClient(); + + const { result } = await renderPermissions(queryClient, 'ws-1'); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.isSuperAdmin).toBe(true); + expect(result.current.canSeeEmailAndPhone).toBe(true); + }); + + it('fails closed while workspaceId is null', async () => { + const queryClient = createTestQueryClient(); + const { result } = await renderPermissions(queryClient, null); + + expect(result.current.isLoading).toBe(true); + expect(result.current.canSeeEmailAndPhone).toBe(false); + }); +}); diff --git a/src/features/permissions/use-permissions.ts b/src/features/permissions/use-permissions.ts new file mode 100644 index 0000000..7500716 --- /dev/null +++ b/src/features/permissions/use-permissions.ts @@ -0,0 +1,109 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import { useAuthStore } from '@/stores/use-auth-store'; + +/** + * `GET /workspaces/{workspaceId}/members` (`workspaceMembersAPI.listWorkspaceMembersAuthenticatedAPI` + * in src/api/generated/schema.ts) embeds a `permissions` object on every member row with EXACTLY + * these field names: + * `{ superAdmin, analytics, flows, contacts, onlyAssignedContacts, emailAndPhone, broadcast, ecommerce }`. + * This hook finds the current signed-in user's own member row (matched by `userId` against the + * auth store) and exposes its permission bits. Shares the same members-list query the + * assignment-sheet already fetches (`queryKeys.ws.members.list`), so no extra request in practice. + */ +export interface WorkspacePermissions { + isSuperAdmin: boolean; + onlyAssignedContacts: boolean; + canSeeEmailAndPhone: boolean; + canSeeAnalytics: boolean; + canSeeFlows: boolean; + canSeeContacts: boolean; + canSeeBroadcast: boolean; + canSeeEcommerce: boolean; + /** True while the underlying members-list query hasn't resolved yet (or the current user's own + * member row hasn't been found in it). Callers that gate a query's `enabled` on permissions + * being settled (e.g. `useConversationsInfinite`'s `onlyAssignedContacts` enforcement) should + * wait on this rather than firing against the fail-closed defaults below. */ + isLoading: boolean; +} + +/** Super admins bypass every restriction, mirroring the backend's own authorization checks. */ +const DEFAULT_PERMISSIONS: Omit<WorkspacePermissions, 'isLoading'> = { + isSuperAdmin: false, + onlyAssignedContacts: false, + canSeeEmailAndPhone: true, + canSeeAnalytics: true, + canSeeFlows: true, + canSeeContacts: true, + canSeeBroadcast: true, + canSeeEcommerce: true, +}; + +/** + * PII-masking-relevant defaults used ONLY while the member row is loading or can't be found. + * Unlike `DEFAULT_PERMISSIONS` (which stays open so the app doesn't lock up other UI on a fetch + * hiccup), `canSeeEmailAndPhone` fails CLOSED here — masking is the one bit where "briefly show + * unmasked PII on every mount" is an actual data-exposure bug (see contact-row.tsx / info-tab.tsx + * consumers), not just a UX inconvenience. `onlyAssignedContacts` also fails closed (scoped to + * "assigned to me") for the same reason: a restricted member's conversation list must never + * briefly show everyone else's conversations while permissions are still loading. + */ +const LOADING_PERMISSIONS: Omit<WorkspacePermissions, 'isLoading'> = { + ...DEFAULT_PERMISSIONS, + onlyAssignedContacts: true, + canSeeEmailAndPhone: false, +}; + +export function useWorkspaceMembersList(workspaceId: string) { + return useQuery({ + queryKey: queryKeys.ws.members.list(workspaceId), + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/members', { + params: { path: { workspaceId } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + enabled: workspaceId.length > 0, + }); +} + +/** Returns the signed-in user's own permission bits for `workspaceId`. While the member row is + * loading or can't be found, PII-masking-relevant bits (`canSeeEmailAndPhone`, + * `onlyAssignedContacts`) fail CLOSED (see LOADING_PERMISSIONS) so a fetch hiccup can't briefly + * expose unmasked contact data or another agent's conversations; other bits stay open by default + * so unrelated UI (flows/analytics/etc. visibility) doesn't lock up on every mount. The backend + * still enforces real authorization server-side regardless of this UI gate. */ +export function usePermissions(workspaceId: string | null): WorkspacePermissions { + const currentUserId = useAuthStore((state) => state.user?.id); + const { data: members, isLoading: isMembersLoading } = useWorkspaceMembersList(workspaceId ?? ''); + + if (!workspaceId || !currentUserId || isMembersLoading || !members) { + return { ...LOADING_PERMISSIONS, isLoading: true }; + } + + const ownMember = members.find((member) => member.userId === currentUserId); + if (!ownMember) { + return { ...LOADING_PERMISSIONS, isLoading: false }; + } + + const { permissions } = ownMember; + if (permissions.superAdmin) { + return { ...DEFAULT_PERMISSIONS, isSuperAdmin: true, isLoading: false }; + } + + return { + isSuperAdmin: false, + onlyAssignedContacts: permissions.onlyAssignedContacts, + canSeeEmailAndPhone: permissions.emailAndPhone, + canSeeAnalytics: permissions.analytics, + canSeeFlows: permissions.flows, + canSeeContacts: permissions.contacts, + canSeeBroadcast: permissions.broadcast, + canSeeEcommerce: permissions.ecommerce, + isLoading: false, + }; +} diff --git a/src/features/sequences/api/use-enroll-contact-in-sequences.test.tsx b/src/features/sequences/api/use-enroll-contact-in-sequences.test.tsx new file mode 100644 index 0000000..758dc87 --- /dev/null +++ b/src/features/sequences/api/use-enroll-contact-in-sequences.test.tsx @@ -0,0 +1,43 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { useEnrollContactInSequences } from './use-enroll-contact-in-sequences'; + +// `FEATURES.sendSequence` is hardcoded `false` in src/config/features.ts — this hook must refuse +// to execute (and never reach `fetch`) while that's the case, per its own doc comment. +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +function wrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + }; +} + +describe('useEnrollContactInSequences', () => { + it('throws before any network call while FEATURES.sendSequence is off', async () => { + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + const queryClient = new QueryClient(); + + const { result } = await renderHook(() => useEnrollContactInSequences('ws-1', 'contact-1'), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate(['seq-1']); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.current.error).toBeInstanceOf(Error); + + queryClient.clear(); + queryClient.unmount(); + }); +}); diff --git a/src/features/sequences/api/use-enroll-contact-in-sequences.ts b/src/features/sequences/api/use-enroll-contact-in-sequences.ts new file mode 100644 index 0000000..2d494f1 --- /dev/null +++ b/src/features/sequences/api/use-enroll-contact-in-sequences.ts @@ -0,0 +1,80 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { getAuthToken } from '@/api/auth-token'; +import { queryKeys } from '@/api/query-keys'; +import { env } from '@/config/env'; +import { FEATURES } from '@/config/features'; + +/** + * Hand-rolled fetch — no generated-client route exists yet for this endpoint (backend needs to + * add `POST /workspaces/{workspaceId}/contacts/sequences`, bearer-auth, mirroring the existing + * `POST /contacts/tags` convention). Replace with the generated client + delete this file once + * shipped and `pnpm generate:api` has been re-run. + * + * Request shape mirrors the web app's Next.js server action body exactly: `{ ids, sequences }`, + * where `ids` are contact ids and `sequences` are sequence ids — matching the + * `POST /workspaces/{workspaceId}/contacts/tags` convention's `{ ids, tags }` shape. + */ +interface EnrollContactsInSequencesRequest { + ids: string[]; + sequences: string[]; +} + +class EnrollSequencesError extends Error {} + +async function postEnrollContactsInSequences( + workspaceId: string, + body: EnrollContactsInSequencesRequest, +): Promise<void> { + const token = await getAuthToken(); + const response = await fetch( + `${env.apiBaseUrl}/api/workspaces/${workspaceId}/contacts/sequences`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + let message = response.statusText; + try { + const errorBody = (await response.json()) as { message?: string }; + message = errorBody.message ?? message; + } catch { + // Non-JSON error body — fall back to statusText. + } + throw new EnrollSequencesError(message); + } +} + +/** + * Enrolls a single contact into one or more sequences. Safely importable/callable even while + * `FEATURES.sendSequence` is off — `mutationFn` throws immediately, before any network call, so + * nothing reaches the (nonexistent) backend route by accident. + */ +export function useEnrollContactInSequences(workspaceId: string, contactId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (sequenceIds: string[]) => { + if (!FEATURES.sendSequence) { + throw new EnrollSequencesError( + 'Send sequence is not yet available — the backend enroll endpoint has not shipped.', + ); + } + await postEnrollContactsInSequences(workspaceId, { + ids: [contactId], + sequences: sequenceIds, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.ws.contacts.detail(workspaceId, contactId), + }); + }, + }); +} diff --git a/src/features/sequences/api/use-sequences.ts b/src/features/sequences/api/use-sequences.ts new file mode 100644 index 0000000..0f093ca --- /dev/null +++ b/src/features/sequences/api/use-sequences.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; +import type { operations } from '@/api/generated/schema'; + +/** + * `sequencesAPI.listSequencesWorkspaceAuthAPI` (`GET /workspaces/{workspaceId}/sequences`) is the + * session-authenticated sequences list (the `WorkspaceTokenAPI` sibling requires a workspace + * token, not a bearer session). Offset-paginated (`page`/`perPage`/`pageCount`), like contacts and + * flows — not the cursor shape. Filters to `active: true` server-side so the picker only ever + * shows sequences that can currently be enrolled into. + */ +type ListSequencesOperation = operations['sequencesAPI.listSequencesWorkspaceAuthAPI']; +export type ListSequencesResponse = + ListSequencesOperation['responses'][200]['content']['application/json']; +export type SequenceListItem = ListSequencesResponse['data'][number]; + +const PER_PAGE = 100; + +export function useSequences(workspaceId: string | null) { + return useQuery({ + queryKey: queryKeys.ws.sequences.list(workspaceId ?? ''), + enabled: workspaceId !== null, + queryFn: async (): Promise<SequenceListItem[]> => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/sequences', { + params: { + path: { workspaceId: workspaceId! }, + query: { page: 1, perPage: PER_PAGE, active: true }, + }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + }); +} diff --git a/src/features/sequences/components/sequence-picker-sheet.tsx b/src/features/sequences/components/sequence-picker-sheet.tsx new file mode 100644 index 0000000..3122f35 --- /dev/null +++ b/src/features/sequences/components/sequence-picker-sheet.tsx @@ -0,0 +1,134 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import type { RefObject } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { Sheet, SheetHeader } from '@/components/ui/sheet'; +import { useToast } from '@/components/ui/toast'; +import { FEATURES } from '@/config/features'; +import { useEnrollContactInSequences } from '@/features/sequences/api/use-enroll-contact-in-sequences'; +import { useSequences, type SequenceListItem } from '@/features/sequences/api/use-sequences'; +import { useTheme } from '@/theme/use-theme'; + +interface SequencePickerSheetProps { + sheetRef: RefObject<BottomSheet | null>; + workspaceId: string; + contactId: string; + onEnrolled?: () => void; +} + +/** + * Multi-select sequence picker, opened from the contact detail Sequences tab's "Add to sequence" + * button. When `FEATURES.sendSequence` is off, the sheet still opens (so the feature stays + * discoverable) but every row renders disabled with a "Coming soon" badge, and selection/submit + * are both no-ops. + */ +export function SequencePickerSheet({ + sheetRef, + workspaceId, + contactId, + onEnrolled, +}: SequencePickerSheetProps) { + const { t } = useTranslation(); + const { colors, spacing } = useTheme(); + const showToast = useToast(); + const [selectedIds, setSelectedIds] = useState<string[]>([]); + + const { data: sequences, isLoading } = useSequences(workspaceId); + const enroll = useEnrollContactInSequences(workspaceId, contactId); + + const activeSequences = (sequences ?? []).filter((sequence) => sequence.active); + + function toggle(sequenceId: string) { + if (!FEATURES.sendSequence) return; + setSelectedIds((prev) => + prev.includes(sequenceId) ? prev.filter((id) => id !== sequenceId) : [...prev, sequenceId], + ); + } + + function handleConfirm() { + if (!FEATURES.sendSequence || selectedIds.length === 0) return; + enroll.mutate(selectedIds, { + onSuccess: () => { + showToast({ message: t('contacts.sequenceEnrolled'), tone: 'success' }); + setSelectedIds([]); + onEnrolled?.(); + sheetRef.current?.close(); + }, + onError: () => { + showToast({ message: t('contacts.sequenceEnrollFailed'), tone: 'danger' }); + }, + }); + } + + return ( + <Sheet ref={sheetRef} snapPoints={['60%', '90%']}> + <SheetHeader title={t('contacts.addToSequence')} onClose={() => sheetRef.current?.close()} /> + {!FEATURES.sendSequence ? ( + <View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}> + <Badge tone="warning">{t('common.comingSoon')}</Badge> + </View> + ) : null} + <BottomSheetFlatList + data={activeSequences} + keyExtractor={(sequence) => sequence.id} + ListEmptyComponent={ + !isLoading ? ( + <EmptyState icon="git-network-outline" title={t('contacts.noActiveSequences')} /> + ) : null + } + renderItem={({ item }: { item: SequenceListItem }) => { + const selected = selectedIds.includes(item.id); + return ( + <ListItem + title={item.name} + subtitle={t('contacts.sequenceSubscribers', { count: item.subscribersCount })} + disabled={!FEATURES.sendSequence} + leading={ + <Icon + name={selected ? 'checkbox' : 'square-outline'} + size={22} + color={selected ? colors.brand : colors.textSecondary} + /> + } + trailing={ + !FEATURES.sendSequence ? ( + <Badge tone="warning">{t('common.comingSoon')}</Badge> + ) : undefined + } + accessibilityState={{ selected }} + onPress={() => toggle(item.id)} + /> + ); + }} + /> + <View + style={[ + styles.footer, + { padding: spacing.md, gap: spacing.sm, borderTopColor: colors.borderSubtle }, + ]} + > + <Button + label={t('contacts.enrollSelected', { count: selectedIds.length })} + onPress={handleConfirm} + disabled={!FEATURES.sendSequence || selectedIds.length === 0} + loading={enroll.isPending} + fullWidth + /> + </View> + </Sheet> + ); +} + +const styles = StyleSheet.create({ + footer: { + borderTopWidth: StyleSheet.hairlineWidth, + }, +}); diff --git a/src/features/settings/api/use-device-token.ts b/src/features/settings/api/use-device-token.ts new file mode 100644 index 0000000..4959203 --- /dev/null +++ b/src/features/settings/api/use-device-token.ts @@ -0,0 +1,94 @@ +import { useMutation } from '@tanstack/react-query'; +import * as Notifications from 'expo-notifications'; +import * as SecureStore from 'expo-secure-store'; +import { useEffect } from 'react'; +import { Platform } from 'react-native'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { getPushTokenAsync } from '@/lib/notifications'; + +/** + * Real Expo push token registration against `PUT/DELETE /users/me/device-tokens` (verified in + * src/api/generated/schema.ts: PUT body `{ workspaceId?, platform: "ios"|"android", token }`, + * DELETE body `{ token }`). The last-registered token is cached in SecureStore so unregister (and + * re-registration after a token rotation) always targets the token the backend actually has on + * file, not whatever `getExpoPushTokenAsync` returns *now* if it has since changed. + */ +const LAST_REGISTERED_TOKEN_KEY = 'chatbotx.lastRegisteredPushToken'; + +function resolvePlatform(): 'ios' | 'android' | null { + if (Platform.OS === 'ios') return 'ios'; + if (Platform.OS === 'android') return 'android'; + return null; // web / other — the backend enum has no matching value, don't attempt registration. +} + +async function registerToken(workspaceId: string, token: string): Promise<void> { + const platform = resolvePlatform(); + if (!platform) throw new Error('Push registration is only supported on iOS and Android.'); + + const { error } = await apiClient.PUT('/users/me/device-tokens', { + body: { workspaceId, platform, token }, + }); + if (error) throw new ApiError(error); + await SecureStore.setItemAsync(LAST_REGISTERED_TOKEN_KEY, token); +} + +async function unregisterToken(token: string): Promise<void> { + const { error } = await apiClient.DELETE('/users/me/device-tokens', { body: { token } }); + if (error) throw new ApiError(error); +} + +export function useRegisterDeviceToken(workspaceId: string) { + return useMutation({ + mutationFn: async () => { + const result = await getPushTokenAsync(); + if (result.status !== 'granted') { + throw new Error(`Push permission ${result.status}`); + } + await registerToken(workspaceId, result.token); + return result.token; + }, + }); +} + +export function useUnregisterDeviceToken() { + return useMutation({ + mutationFn: async () => { + const token = await SecureStore.getItemAsync(LAST_REGISTERED_TOKEN_KEY); + if (!token) return; + await unregisterToken(token); + await SecureStore.deleteItemAsync(LAST_REGISTERED_TOKEN_KEY); + }, + }); +} + +/** + * Expo can issue a new push token at any time (token rotation) — this re-registers automatically + * whenever that happens, but only while the user has push enabled (tracked by the caller via + * `enabled`), and unregisters the stale token first so the backend never holds two tokens for one + * install. + */ +export function usePushTokenRotation(workspaceId: string, enabled: boolean): void { + useEffect(() => { + if (!enabled) return undefined; + + let cancelled = false; + + const subscription = Notifications.addPushTokenListener(async ({ data: newToken }) => { + const previousToken = await SecureStore.getItemAsync(LAST_REGISTERED_TOKEN_KEY); + if (cancelled) return; + + if (previousToken && previousToken !== newToken) { + await unregisterToken(previousToken).catch(() => {}); + if (cancelled) return; + } + await registerToken(workspaceId, newToken); + }); + + return () => { + cancelled = true; + subscription.remove(); + }; + }, [enabled, workspaceId]); +} diff --git a/src/features/settings/api/use-teams.ts b/src/features/settings/api/use-teams.ts new file mode 100644 index 0000000..4c44384 --- /dev/null +++ b/src/features/settings/api/use-teams.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; + +/** + * `GET /workspaces/{workspaceId}/inbox-teams` (`inboxTeamsAPI.listInboxTeamsAuthenticatedAPI`) — + * an enterprise-tier feature; not every workspace/plan has it — the members screen treats any + * error here as "no teams" (hides the section) rather than showing a broken error state, since a + * 403/404 from an unlicensed workspace is an expected outcome, not a real failure to surface to + * the user. + */ +export function useTeamsList(workspaceId: string) { + return useQuery({ + queryKey: queryKeys.ws.teams.list(workspaceId), + queryFn: async () => { + const { data, error } = await apiClient.GET('/workspaces/{workspaceId}/inbox-teams', { + params: { path: { workspaceId } }, + }); + if (error) throw new ApiError(error); + return data.data; + }, + enabled: workspaceId.length > 0, + retry: false, // an unlicensed workspace's 403 shouldn't retry — resolve to "hidden" quickly. + }); +} diff --git a/src/features/settings/components/language-sheet.tsx b/src/features/settings/components/language-sheet.tsx new file mode 100644 index 0000000..c10df02 --- /dev/null +++ b/src/features/settings/components/language-sheet.tsx @@ -0,0 +1,45 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import type { RefObject } from 'react'; +import { View } from 'react-native'; + +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { Sheet } from '@/components/ui/sheet'; +import { changeAppLanguage } from '@/i18n/apply-language'; +import { localeMeta, supportedLanguages, type SupportedLanguage } from '@/i18n/locales'; +import { useTheme } from '@/theme/use-theme'; + +interface LanguageSheetProps { + sheetRef: RefObject<BottomSheet | null>; + currentLanguage: SupportedLanguage; +} + +export function LanguageSheet({ sheetRef, currentLanguage }: LanguageSheetProps) { + const { colors } = useTheme(); + + return ( + <Sheet ref={sheetRef} snapPoints={['60%', '90%']}> + <BottomSheetFlatList + data={supportedLanguages} + keyExtractor={(language) => language} + renderItem={({ item: language }) => ( + <ListItem + title={localeMeta[language].nativeLabel} + trailing={ + language === currentLanguage ? ( + <View> + <Icon name="checkmark" size={20} color={colors.brand} /> + </View> + ) : undefined + } + onPress={() => { + sheetRef.current?.close(); + void changeAppLanguage(language); + }} + /> + )} + /> + </Sheet> + ); +} diff --git a/src/features/workspaces/api/use-switch-workspace.test.tsx b/src/features/workspaces/api/use-switch-workspace.test.tsx new file mode 100644 index 0000000..80f5d35 --- /dev/null +++ b/src/features/workspaces/api/use-switch-workspace.test.tsx @@ -0,0 +1,125 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-native'; +import { act } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { useSwitchWorkspace } from '@/features/workspaces/api/use-switch-workspace'; +import { useConversationFilters } from '@/features/conversations/stores/use-conversation-filters'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +const mockReplace = jest.fn(); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ replace: mockReplace }), +})); + +function seedQueries(queryClient: QueryClient, workspaceId: string) { + queryClient.setQueryData(['ws', workspaceId, 'conversations', 'list', {}], { pages: [] }); + queryClient.setQueryData(['ws', workspaceId, 'contacts', 'list', {}], { pages: [] }); +} + +// `renderHook` in this @testing-library/react-native version is async (it awaits an internal +// `render()` call) — every call site must `await` it or `result` resolves to a Promise instead of +// the ref object, and `result.current` throws. +function renderSwitchWorkspace(queryClient: QueryClient) { + function wrapper({ children }: PropsWithChildren) { + return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; + } + return renderHook(() => useSwitchWorkspace(), { wrapper }); +} + +const activeQueryClients: QueryClient[] = []; + +/** Tracks every QueryClient created in a test so it can be torn down afterward — a live + * QueryClient schedules per-query `gcTime` timers (and focus/online listeners via `unmount()`) + * that otherwise keep the Jest process alive past the test run. */ +function createTestQueryClient(): QueryClient { + const queryClient = new QueryClient(); + activeQueryClients.push(queryClient); + return queryClient; +} + +describe('useSwitchWorkspace', () => { + beforeEach(() => { + mockReplace.mockClear(); + useWorkspaceStore.setState({ currentWorkspaceId: null }); + useConversationFilters.getState().reset(); + }); + + afterEach(() => { + while (activeQueryClients.length > 0) { + const queryClient = activeQueryClients.pop()!; + queryClient.clear(); + queryClient.unmount(); + } + }); + + it('removes cached queries for the previous workspace only', async () => { + const queryClient = createTestQueryClient(); + seedQueries(queryClient, 'ws-old'); + seedQueries(queryClient, 'ws-new'); + useWorkspaceStore.setState({ currentWorkspaceId: 'ws-old' }); + + const { result } = await renderSwitchWorkspace(queryClient); + act(() => { + result.current('ws-new', { navigate: true }); + }); + + expect(queryClient.getQueryData(['ws', 'ws-old', 'conversations', 'list', {}])).toBeUndefined(); + expect(queryClient.getQueryData(['ws', 'ws-new', 'conversations', 'list', {}])).toBeDefined(); + }); + + it('sets the new workspace id and navigates to conversations when opts.navigate is true', async () => { + const queryClient = createTestQueryClient(); + useWorkspaceStore.setState({ currentWorkspaceId: 'ws-old' }); + + const { result } = await renderSwitchWorkspace(queryClient); + act(() => { + result.current('ws-new', { navigate: true }); + }); + + expect(useWorkspaceStore.getState().currentWorkspaceId).toBe('ws-new'); + expect(mockReplace).toHaveBeenCalledWith('/(app)/(tabs)/conversations'); + }); + + it('does not navigate when opts.navigate is omitted for a non-null workspace', async () => { + const queryClient = createTestQueryClient(); + useWorkspaceStore.setState({ currentWorkspaceId: 'ws-old' }); + + const { result } = await renderSwitchWorkspace(queryClient); + act(() => { + result.current('ws-new'); + }); + + expect(useWorkspaceStore.getState().currentWorkspaceId).toBe('ws-new'); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('navigates to the workspace picker and clears the workspace id when next is null', async () => { + const queryClient = createTestQueryClient(); + useWorkspaceStore.setState({ currentWorkspaceId: 'ws-old' }); + + const { result } = await renderSwitchWorkspace(queryClient); + act(() => { + result.current(null); + }); + + expect(useWorkspaceStore.getState().currentWorkspaceId).toBeNull(); + expect(mockReplace).toHaveBeenCalledWith('/(app)/workspace-picker'); + }); + + it('resets the conversation filters store on switch', async () => { + const queryClient = createTestQueryClient(); + useWorkspaceStore.setState({ currentWorkspaceId: 'ws-old' }); + useConversationFilters.getState().setKeyword('hello'); + useConversationFilters.getState().setAssignedId('user-1'); + + const { result } = await renderSwitchWorkspace(queryClient); + act(() => { + result.current('ws-new'); + }); + + expect(useConversationFilters.getState().keyword).toBe(''); + expect(useConversationFilters.getState().assignedId).toBeUndefined(); + }); +}); diff --git a/src/features/workspaces/api/use-switch-workspace.ts b/src/features/workspaces/api/use-switch-workspace.ts new file mode 100644 index 0000000..505a84f --- /dev/null +++ b/src/features/workspaces/api/use-switch-workspace.ts @@ -0,0 +1,59 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; + +import { isWorkspaceQuery } from '@/api/query-keys'; +import { useConversationFilters } from '@/features/conversations/stores/use-conversation-filters'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +interface SwitchWorkspaceOptions { + /** When true, navigates to the conversations tab after switching (used by an inline switch from + * the workspace switcher sheet). When `next` is null, this is ignored — the workspace picker is + * always the destination for "no workspace selected". */ + navigate?: boolean; +} + +/** + * Single source of truth for the "switch workspace" flow, replacing the three near-identical + * blocks that used to live in settings/index.tsx, workspace-blocked-gate.tsx, and + * workspace-picker.tsx. Steps: drop every cached query for the OLD workspace (so a re-entered + * workspace never shows stale data from a previous session), reset the conversation filters store + * (filters are workspace-agnostic UI state but shouldn't leak a stale "assigned to me" filter into + * a workspace where that user id is meaningless), select the new workspace, then navigate. + * + * `next: null` clears the selection entirely and always routes to the workspace picker (sign-out + * style / "switch workspace" from a blocked gate). A non-null `next` optionally navigates to the + * inbox when `opts.navigate` is set — the workspace switcher sheet uses this for an inline switch + * that doesn't require leaving the current tab stack; callers that already own their own navigation + * (e.g. the picker screen selecting a workspace for the first time) can omit it. + */ +export function useSwitchWorkspace() { + const queryClient = useQueryClient(); + const router = useRouter(); + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const setCurrentWorkspaceId = useWorkspaceStore((state) => state.setCurrentWorkspaceId); + const resetConversationFilters = useConversationFilters((state) => state.reset); + + return useCallback( + (next: string | null, opts?: SwitchWorkspaceOptions) => { + if (currentWorkspaceId) { + queryClient.removeQueries({ + predicate: (query) => isWorkspaceQuery(query.queryKey, currentWorkspaceId), + }); + } + + resetConversationFilters(); + setCurrentWorkspaceId(next); + + if (next === null) { + router.replace('/(app)/workspace-picker'); + return; + } + + if (opts?.navigate) { + router.replace('/(app)/(tabs)/conversations'); + } + }, + [currentWorkspaceId, queryClient, resetConversationFilters, setCurrentWorkspaceId, router], + ); +} diff --git a/src/features/workspaces/api/use-workspaces.ts b/src/features/workspaces/api/use-workspaces.ts new file mode 100644 index 0000000..71d6d18 --- /dev/null +++ b/src/features/workspaces/api/use-workspaces.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/api/client'; +import { ApiError } from '@/api/errors'; +import { queryKeys } from '@/api/query-keys'; + +export interface Workspace { + id: string; + name: string; + logo: string | null; + brandColor: string; +} + +/** Lists workspaces the current user is a member of (`GET /users/me/workspaces`). + * Global key — not workspace-scoped, since it lists across workspaces. */ +export function useWorkspaces() { + return useQuery({ + queryKey: queryKeys.workspaces(), + queryFn: async (): Promise<Workspace[]> => { + const { data, error } = await apiClient.GET('/users/me/workspaces'); + if (error) { + throw new ApiError(error); + } + return data.workspaces; + }, + }); +} diff --git a/src/features/workspaces/components/workspace-pill.tsx b/src/features/workspaces/components/workspace-pill.tsx new file mode 100644 index 0000000..336c083 --- /dev/null +++ b/src/features/workspaces/components/workspace-pill.tsx @@ -0,0 +1,56 @@ +import { Pressable, StyleSheet } from 'react-native'; + +import { Avatar } from '@/components/ui/avatar'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useTheme } from '@/theme/use-theme'; + +interface WorkspacePillProps { + name: string; + logo?: string | null; + onPress: () => void; +} + +/** Inbox header affordance: current workspace's avatar + name, tapping opens the workspace + * switcher sheet. Kept as a small standalone component (rather than inlined in the conversations + * screen) so Settings' "Switch workspace" row and any future header reuse it identically. */ +export function WorkspacePill({ name, logo, onPress }: WorkspacePillProps) { + const { colors, spacing, radius } = useTheme(); + + return ( + <Pressable + accessibilityRole="button" + accessibilityLabel={name} + onPress={onPress} + style={({ pressed }) => [ + styles.pill, + { + backgroundColor: colors.surface2, + borderRadius: radius.full, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.ms, + gap: spacing.xs, + opacity: pressed ? 0.7 : 1, + }, + ]} + > + <Avatar uri={logo} name={name} size={24} /> + <Text variant="caption" numberOfLines={1} style={styles.name}> + {name} + </Text> + <Icon name="chevron-down" size={14} color={colors.textSecondary} /> + </Pressable> + ); +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + maxWidth: 200, + }, + name: { + flexShrink: 1, + }, +}); diff --git a/src/features/workspaces/components/workspace-switcher-sheet.tsx b/src/features/workspaces/components/workspace-switcher-sheet.tsx new file mode 100644 index 0000000..562735f --- /dev/null +++ b/src/features/workspaces/components/workspace-switcher-sheet.tsx @@ -0,0 +1,73 @@ +import type BottomSheet from '@gorhom/bottom-sheet'; +import { BottomSheetFlatList } from '@gorhom/bottom-sheet'; +import { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert } from 'react-native'; + +import { EmptyState } from '@/components/ui/empty-state'; +import { Sheet } from '@/components/ui/sheet'; +import { useSwitchWorkspace } from '@/features/workspaces/api/use-switch-workspace'; +import { useWorkspaces, type Workspace } from '@/features/workspaces/api/use-workspaces'; +import { WorkspaceRow } from '@/features/workspaces/workspace-row'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** + * Inline workspace switcher, opened from the inbox header pill and from Settings' "Switch + * workspace" row — replaces navigating away to the full-screen picker for the common case of + * hopping between a small number of workspaces. Picking a DIFFERENT workspace confirms first + * (switching drops every cached query for the current one), then switches and closes; picking the + * already-active workspace just closes the sheet. + */ +export const WorkspaceSwitcherSheet = forwardRef<BottomSheet>( + function WorkspaceSwitcherSheet(_props, ref) { + const { t } = useTranslation(); + const { data: workspaces, isPending } = useWorkspaces(); + const currentWorkspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const switchWorkspace = useSwitchWorkspace(); + + function closeSheet() { + if (ref && 'current' in ref) { + ref.current?.close(); + } + } + + function handleSelect(workspace: Workspace) { + if (workspace.id === currentWorkspaceId) { + closeSheet(); + return; + } + + Alert.alert( + t('workspaces.switchConfirmTitle'), + t('workspaces.switchConfirmBody', { name: workspace.name }), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('workspaces.switch'), + onPress: () => { + switchWorkspace(workspace.id, { navigate: true }); + closeSheet(); + }, + }, + ], + ); + } + + return ( + <Sheet ref={ref} snapPoints={['50%', '80%']}> + <BottomSheetFlatList + data={isPending ? [] : (workspaces ?? [])} + keyExtractor={(item) => item.id} + ListEmptyComponent={<EmptyState icon="business-outline" title={t('workspaces.empty')} />} + renderItem={({ item }) => ( + <WorkspaceRow + workspace={item} + selected={item.id === currentWorkspaceId} + onPress={handleSelect} + /> + )} + /> + </Sheet> + ); + }, +); diff --git a/src/features/workspaces/workspace-row.tsx b/src/features/workspaces/workspace-row.tsx new file mode 100644 index 0000000..1179116 --- /dev/null +++ b/src/features/workspaces/workspace-row.tsx @@ -0,0 +1,31 @@ +import { Avatar } from '@/components/ui/avatar'; +import { Icon } from '@/components/ui/icon'; +import { ListItem } from '@/components/ui/list-item'; +import { useTheme } from '@/theme/use-theme'; + +import type { Workspace } from './api/use-workspaces'; + +interface WorkspaceRowProps { + workspace: Workspace; + onPress: (workspace: Workspace) => void; + /** Marks this row as the currently active workspace — shows a checkmark instead of the + * chevron. */ + selected?: boolean; +} + +export function WorkspaceRow({ workspace, onPress, selected = false }: WorkspaceRowProps) { + const { colors } = useTheme(); + + return ( + <ListItem + title={workspace.name} + leading={<Avatar uri={workspace.logo} name={workspace.name} size={44} />} + trailing={ + selected ? <Icon name="checkmark-circle" size={22} color={colors.brand} /> : undefined + } + onPress={() => onPress(workspace)} + showChevron={!selected} + accessibilityState={{ selected }} + /> + ); +} diff --git a/src/i18n/apply-language.ts b/src/i18n/apply-language.ts new file mode 100644 index 0000000..553d70a --- /dev/null +++ b/src/i18n/apply-language.ts @@ -0,0 +1,70 @@ +import { Alert, DevSettings, I18nManager } from 'react-native'; +import * as Updates from 'expo-updates'; + +import { i18next } from '@/i18n'; +import { setDayjsLocale } from '@/i18n/dayjs-locales'; +import { isRTL, type SupportedLanguage } from '@/i18n/locales'; +import { useSettingsStore } from '@/stores/use-settings-store'; + +/** Applies a language to the running app's i18next + dayjs instances. Does not persist it or + * reconcile RTL — see `changeAppLanguage` for the full user-facing flow. */ +export async function applyLanguage(language: SupportedLanguage): Promise<void> { + await i18next.changeLanguage(language); + setDayjsLocale(language); +} + +/** + * Reloads the app JS bundle. `Updates.reloadAsync` is a native-production API that throws in + * `__DEV__`/Expo Go (no update runtime available there), so we use `DevSettings.reload()` + * instead in those environments. `Updates.isEnabled` reflects whether the `expo-updates` native + * module is actually linked into *this* build — it can be false in a production build if the + * app was built before the `expo-updates` package/plugin were added (needs a fresh build to pick + * up the native module), independent of whether EAS Update hosting is configured. In that case + * there is no reload mechanism available at all; the RTL/language change still takes effect on + * the *next* natural app relaunch. `silent: true` (the bootstrap path, before anything has + * rendered) falls back to that quietly instead of alerting — there's no user-initiated action to + * report back on yet. + */ +export async function reloadApp(options?: { silent?: boolean }): Promise<void> { + if (__DEV__) { + DevSettings.reload(); + return; + } + if (!Updates.isEnabled) { + if (!options?.silent) { + Alert.alert( + i18next.t('settings.restartRequiredTitle'), + i18next.t('settings.restartManuallyMessage'), + ); + } + return; + } + await Updates.reloadAsync(); +} + +/** + * Full user-initiated language change: persist the choice, apply it live, and — if the new + * language's writing direction differs from the current `I18nManager` direction — force the RTL + * flag and prompt the user to restart (native layout direction cannot flip without a reload). + */ +export async function changeAppLanguage(language: SupportedLanguage): Promise<void> { + useSettingsStore.getState().setLanguage(language); + await applyLanguage(language); + + const targetIsRTL = isRTL(language); + if (targetIsRTL === I18nManager.isRTL) { + return; + } + + I18nManager.allowRTL(true); + I18nManager.forceRTL(targetIsRTL); + + Alert.alert( + i18next.t('settings.restartRequiredTitle'), + i18next.t('settings.restartRequiredMessage'), + [ + { text: i18next.t('settings.restartLater'), style: 'cancel' }, + { text: i18next.t('settings.restartNow'), onPress: () => void reloadApp() }, + ], + ); +} diff --git a/src/i18n/dayjs-locales.test.ts b/src/i18n/dayjs-locales.test.ts new file mode 100644 index 0000000..222b58d --- /dev/null +++ b/src/i18n/dayjs-locales.test.ts @@ -0,0 +1,31 @@ +import dayjs from 'dayjs'; + +import { setDayjsLocale } from '@/i18n/dayjs-locales'; + +afterEach(() => { + dayjs.locale('en'); +}); + +describe('setDayjsLocale', () => { + it('maps pt-BR to the pt-br dayjs locale', () => { + setDayjsLocale('pt-BR'); + expect(dayjs.locale()).toBe('pt-br'); + }); + + it('maps pt-PT to the pt dayjs locale (dayjs has no distinct pt-pt)', () => { + setDayjsLocale('pt-PT'); + expect(dayjs.locale()).toBe('pt'); + }); + + it('maps zh-CN and zh-TW to their lowercase dayjs equivalents', () => { + setDayjsLocale('zh-CN'); + expect(dayjs.locale()).toBe('zh-cn'); + setDayjsLocale('zh-TW'); + expect(dayjs.locale()).toBe('zh-tw'); + }); + + it('maps a simple language like vi to its identity lowercase locale', () => { + setDayjsLocale('vi'); + expect(dayjs.locale()).toBe('vi'); + }); +}); diff --git a/src/i18n/dayjs-locales.ts b/src/i18n/dayjs-locales.ts new file mode 100644 index 0000000..cd27ef8 --- /dev/null +++ b/src/i18n/dayjs-locales.ts @@ -0,0 +1,78 @@ +import dayjs from 'dayjs'; + +import type { SupportedLanguage } from '@/i18n/locales'; + +/** + * dayjs has no `pt-pt` locale — `pt` IS European Portuguese there, and Brazilian Portuguese is + * the separate `pt-br` locale. This mapping must stay in sync with that dayjs convention. + */ +const dayjsLocaleNames: Record<SupportedLanguage, string> = { + ar: 'ar', + da: 'da', + de: 'de', + en: 'en', + es: 'es', + fi: 'fi', + fr: 'fr', + he: 'he', + id: 'id', + it: 'it', + ja: 'ja', + nl: 'nl', + 'pt-BR': 'pt-br', + 'pt-PT': 'pt', + ro: 'ro', + sv: 'sv', + tr: 'tr', + vi: 'vi', + 'zh-CN': 'zh-cn', + 'zh-TW': 'zh-tw', +}; + +// Metro (unlike webpack) needs a STATIC, literal string argument to `require()` to resolve and +// bundle a module — a template-literal path like `require(\`dayjs/locale/${name}\`)` can't be +// analyzed at bundle time, so each locale needs its own literal `require()` call here rather than +// a single dynamically-built one. Still lazy: nothing runs until `setDayjsLocale` actually picks +// this branch, so a session only pays for the locale(s) it uses instead of importing all 20 at +// module load. +/* eslint-disable @typescript-eslint/no-require-imports -- intentionally lazy per-locale requires, see above */ +const dayjsLocaleLoaders: Record<string, () => void> = { + ar: () => require('dayjs/locale/ar'), + da: () => require('dayjs/locale/da'), + de: () => require('dayjs/locale/de'), + en: () => require('dayjs/locale/en'), + es: () => require('dayjs/locale/es'), + fi: () => require('dayjs/locale/fi'), + fr: () => require('dayjs/locale/fr'), + he: () => require('dayjs/locale/he'), + id: () => require('dayjs/locale/id'), + it: () => require('dayjs/locale/it'), + ja: () => require('dayjs/locale/ja'), + nl: () => require('dayjs/locale/nl'), + 'pt-br': () => require('dayjs/locale/pt-br'), + pt: () => require('dayjs/locale/pt'), + ro: () => require('dayjs/locale/ro'), + sv: () => require('dayjs/locale/sv'), + tr: () => require('dayjs/locale/tr'), + vi: () => require('dayjs/locale/vi'), + 'zh-cn': () => require('dayjs/locale/zh-cn'), + 'zh-tw': () => require('dayjs/locale/zh-tw'), +}; +/* eslint-enable @typescript-eslint/no-require-imports */ + +// Tracks which locale modules have already been loaded — dayjs locale side-effect modules are +// idempotent to re-require, but there's no reason to re-run the loader for a locale already +// loaded this session. +const loadedLocales = new Set<string>(); + +function ensureDayjsLocaleLoaded(dayjsLocaleName: string): void { + if (loadedLocales.has(dayjsLocaleName)) return; + loadedLocales.add(dayjsLocaleName); + dayjsLocaleLoaders[dayjsLocaleName]?.(); +} + +export function setDayjsLocale(language: SupportedLanguage): void { + const dayjsLocaleName = dayjsLocaleNames[language]; + ensureDayjsLocaleLoaded(dayjsLocaleName); + dayjs.locale(dayjsLocaleName); +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..772e6d9 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,61 @@ +import * as Localization from 'expo-localization'; +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { defaultLanguage, resolveLocale } from '@/i18n/locales'; +import { defaultNamespace } from '@/i18n/namespace'; +import { resources } from '@/i18n/resources'; + +export { + defaultLanguage, + isRTL, + isSupportedLanguage, + localeMeta, + resolveLocale, + supportedLanguages, +} from '@/i18n/locales'; +export type { SupportedLanguage } from '@/i18n/locales'; +export { defaultNamespace } from '@/i18n/namespace'; + +function resolveDeviceLanguage() { + // `languageTag` is the full BCP-47 tag (e.g. `zh-Hant-TW`, `pt-PT`) — `languageCode` would only + // give the primary subtag and lose the region info resolveLocale needs for zh/pt normalization. + return resolveLocale(Localization.getLocales()[0]?.languageTag); +} + +let initialized = false; + +/** + * Initializes i18next once. Safe to call multiple times (e.g. across fast-refresh reloads) — + * only the first call performs setup. Uses the device-resolved language; the persisted user + * preference (if different) is applied afterwards via `applyLanguage` in the app bootstrap gate, + * since settings-store hydration is async and can't block this synchronous module-load init. + */ +export function initI18n(): typeof i18next { + if (initialized) { + return i18next; + } + + // i18next's default export intentionally also carries `.use()`/`.init()` as instance methods — + // this is the library's documented API shape, not an accidental default-vs-named mismatch. + // eslint-disable-next-line import/no-named-as-default-member + i18next.use(initReactI18next).init({ + resources, + lng: resolveDeviceLanguage(), + fallbackLng: defaultLanguage, + defaultNS: defaultNamespace, + interpolation: { + escapeValue: false, + }, + // React Native has no XSS/HTML-injection concerns from interpolation, and Suspense adds + // complexity we don't need for static locale files loaded synchronously at startup. + react: { + useSuspense: false, + }, + }); + + initialized = true; + return i18next; +} + +export { i18next }; diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts new file mode 100644 index 0000000..f8bec25 --- /dev/null +++ b/src/i18n/locales.test.ts @@ -0,0 +1,75 @@ +import { isRTL, isSupportedLanguage, resolveLocale } from '@/i18n/locales'; + +describe('resolveLocale', () => { + it('returns en for undefined', () => { + expect(resolveLocale(undefined)).toBe('en'); + }); + + it('passes through an exact supported tag', () => { + expect(resolveLocale('en')).toBe('en'); + expect(resolveLocale('vi')).toBe('vi'); + expect(resolveLocale('pt-PT')).toBe('pt-PT'); + }); + + it('normalizes bare pt to pt-BR', () => { + expect(resolveLocale('pt')).toBe('pt-BR'); + }); + + it('normalizes any pt-* region tag to pt-BR (pt-PT only passes through exactly)', () => { + expect(resolveLocale('pt-MZ')).toBe('pt-BR'); + }); + + it('normalizes zh-Hant-HK to zh-TW', () => { + expect(resolveLocale('zh-Hant-HK')).toBe('zh-TW'); + }); + + it('normalizes zh-TW-flavored tags to zh-TW', () => { + expect(resolveLocale('zh-TW')).toBe('zh-TW'); + expect(resolveLocale('zh-HK')).toBe('zh-TW'); + expect(resolveLocale('zh-MO')).toBe('zh-TW'); + }); + + it('normalizes zh-CN/zh-Hans to zh-CN', () => { + expect(resolveLocale('zh-CN')).toBe('zh-CN'); + expect(resolveLocale('zh-Hans')).toBe('zh-CN'); + }); + + it('normalizes bare zh to zh-CN', () => { + expect(resolveLocale('zh')).toBe('zh-CN'); + }); + + it('matches on primary subtag for a region variant not otherwise special-cased', () => { + expect(resolveLocale('de-AT')).toBe('de'); + }); + + it('falls back to en for an unsupported language', () => { + expect(resolveLocale('xx-YY')).toBe('en'); + }); + + it('falls back to en for an empty string', () => { + expect(resolveLocale('')).toBe('en'); + }); +}); + +describe('isSupportedLanguage', () => { + it('returns true for supported tags', () => { + expect(isSupportedLanguage('en')).toBe(true); + expect(isSupportedLanguage('zh-TW')).toBe(true); + }); + + it('returns false for unsupported tags', () => { + expect(isSupportedLanguage('xx')).toBe(false); + }); +}); + +describe('isRTL', () => { + it('returns true for ar and he', () => { + expect(isRTL('ar')).toBe(true); + expect(isRTL('he')).toBe(true); + }); + + it('returns false for ltr languages', () => { + expect(isRTL('en')).toBe(false); + expect(isRTL('vi')).toBe(false); + }); +}); diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts new file mode 100644 index 0000000..1b3df78 --- /dev/null +++ b/src/i18n/locales.ts @@ -0,0 +1,102 @@ +export const supportedLanguages = [ + 'ar', + 'da', + 'de', + 'en', + 'es', + 'fi', + 'fr', + 'he', + 'id', + 'it', + 'ja', + 'nl', + 'pt-BR', + 'pt-PT', + 'ro', + 'sv', + 'tr', + 'vi', + 'zh-CN', + 'zh-TW', +] as const; + +export type SupportedLanguage = (typeof supportedLanguages)[number]; +export const defaultLanguage: SupportedLanguage = 'en'; + +export const localeMeta: Record<SupportedLanguage, { nativeLabel: string; dir: 'ltr' | 'rtl' }> = { + ar: { nativeLabel: 'العربية', dir: 'rtl' }, + da: { nativeLabel: 'Dansk', dir: 'ltr' }, + de: { nativeLabel: 'Deutsch', dir: 'ltr' }, + en: { nativeLabel: 'English', dir: 'ltr' }, + es: { nativeLabel: 'Español', dir: 'ltr' }, + fi: { nativeLabel: 'Suomi', dir: 'ltr' }, + fr: { nativeLabel: 'Français', dir: 'ltr' }, + he: { nativeLabel: 'עברית', dir: 'rtl' }, + id: { nativeLabel: 'Bahasa Indonesia', dir: 'ltr' }, + it: { nativeLabel: 'Italiano', dir: 'ltr' }, + ja: { nativeLabel: '日本語', dir: 'ltr' }, + nl: { nativeLabel: 'Nederlands', dir: 'ltr' }, + 'pt-BR': { nativeLabel: 'Português (Brasil)', dir: 'ltr' }, + 'pt-PT': { nativeLabel: 'Português (Portugal)', dir: 'ltr' }, + ro: { nativeLabel: 'Română', dir: 'ltr' }, + sv: { nativeLabel: 'Svenska', dir: 'ltr' }, + tr: { nativeLabel: 'Türkçe', dir: 'ltr' }, + vi: { nativeLabel: 'Tiếng Việt', dir: 'ltr' }, + 'zh-CN': { nativeLabel: '简体中文', dir: 'ltr' }, + 'zh-TW': { nativeLabel: '繁體中文', dir: 'ltr' }, +}; + +export function isSupportedLanguage(value: string): value is SupportedLanguage { + return (supportedLanguages as readonly string[]).includes(value); +} + +export function isRTL(language: SupportedLanguage): boolean { + return localeMeta[language].dir === 'rtl'; +} + +function matchesLocaleTag(normalizedValue: string, localeTag: string) { + return normalizedValue === localeTag || normalizedValue.startsWith(`${localeTag}-`); +} + +/** + * Verbatim port of the web app's resolveLocale so device-language resolution matches the web + * product exactly (pt/zh normalization, RTL locales). + */ +export function resolveLocale(value: string | undefined): SupportedLanguage { + if (!value) { + return defaultLanguage; + } + + const normalizedValue = value.trim(); + if (isSupportedLanguage(normalizedValue)) { + return normalizedValue; + } + + const normalizedLower = normalizedValue.toLowerCase(); + const language = normalizedLower.split('-')[0]; + if (!language) { + return defaultLanguage; + } + if (language === 'pt') { + return 'pt-BR'; + } + if ( + ['zh-hant', 'zh-hk', 'zh-mo', 'zh-tw'].some((localeTag) => + matchesLocaleTag(normalizedLower, localeTag), + ) + ) { + return 'zh-TW'; + } + if (['zh-cn', 'zh-hans'].some((localeTag) => matchesLocaleTag(normalizedLower, localeTag))) { + return 'zh-CN'; + } + if (language === 'zh') { + return 'zh-CN'; + } + + return ( + supportedLanguages.find((locale) => locale.toLowerCase().split('-')[0] === language) ?? + defaultLanguage + ); +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json new file mode 100644 index 0000000..ace3ef7 --- /dev/null +++ b/src/i18n/locales/ar.json @@ -0,0 +1,264 @@ +{ + "common": { + "loading": "جارٍ التحميل…", + "error": "حدث خطأ ما", + "retry": "إعادة المحاولة", + "cancel": "إلغاء", + "save": "حفظ", + "done": "تم", + "search": "بحث", + "confirm": "تأكيد", + "delete": "حذف", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "تسجيل الدخول", + "email": "البريد الإلكتروني", + "password": "كلمة المرور", + "forgotPassword": "هل نسيت كلمة المرور؟", + "signOut": "تسجيل الخروج", + "changePassword": "تغيير كلمة المرور", + "changePasswordRequired": "يجب عليك تعيين كلمة مرور جديدة قبل المتابعة.", + "currentPassword": "كلمة المرور الحالية", + "newPassword": "كلمة المرور الجديدة", + "sendResetLink": "إرسال رابط إعادة التعيين", + "resetLinkSent": "إذا كان هناك حساب مرتبط بهذا البريد الإلكتروني، فقد تم إرسال رابط إعادة التعيين.", + "orContinueWith": "أو تابع باستخدام", + "continueWithGoogle": "المتابعة باستخدام Google", + "continueWithFacebook": "المتابعة باستخدام Facebook", + "socialSignInFailed": "لم يكتمل تسجيل الدخول. يرجى المحاولة مرة أخرى.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "مساحات العمل", + "empty": "لا توجد مساحات عمل بعد", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "المحادثات", + "empty": "لا توجد محادثات", + "filters": "التصفية", + "channel": "القناة", + "handledBy": "تمت معالجتها بواسطة", + "status": "الحالة", + "clearFilters": "مسح التصفية", + "filterAll": "الكل", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "الدردشة عبر الموقع", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "البريد الإلكتروني", + "channelTiktok": "TikTok", + "statusUnread": "غير مقروءة", + "statusNoAdminReply": "لا يوجد رد من الفريق", + "statusFollowUp": "قيد المتابعة", + "statusArchived": "مؤرشفة", + "statusBlocked": "محظورة", + "handledByBot": "يتولاها الروبوت", + "handledByHuman": "يتولاها شخص", + "unassign": "إلغاء الإسناد", + "noOtherMembers": "لا يوجد أعضاء آخرون في مساحة العمل هذه بعد.", + "unnamed": "بدون اسم", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "اكتب رسالة", + "send": "إرسال", + "failedToSend": "فشل الإرسال", + "failedToSendTapRetry": "فشل الإرسال. اضغط لإعادة المحاولة.", + "retrySend": "إعادة المحاولة", + "noMessages": "لا توجد رسائل بعد", + "edit": "تعديل", + "like": "إعجاب", + "hide": "إخفاء", + "delete": "حذف", + "searchSavedReplies": "البحث في الردود المحفوظة", + "noSavedReplies": "لا توجد ردود محفوظة", + "attachmentTooLarge": "حجم {{fileName}} أكبر من 5 ميغابايت.", + "quotaMac": "وصلت مساحة العمل هذه إلى حد جهات الاتصال في الخطة الحالية. قم بالترقية لمتابعة إرسال الرسائل.", + "quotaTrialExpired": "انتهت الفترة التجريبية أو خطة مساحة العمل هذه. قم بالترقية لمتابعة إرسال الرسائل.", + "quotaUnknown": "مساحة العمل هذه غير متاحة حاليًا.", + "dismiss": "تجاهل", + "grantCameraPermission": "الوصول إلى الكاميرا مطلوب لالتقاط صورة.", + "grantPermission": "منح الإذن", + "failedToSendPhoto": "فشل إرسال الصورة.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "جهات الاتصال", + "contactTitle": "جهة الاتصال", + "empty": "لا توجد جهات اتصال", + "unknown": "غير معروف", + "blocked": "محظور", + "email": "البريد الإلكتروني", + "phone": "الهاتف", + "locale": "اللغة", + "timezone": "المنطقة الزمنية", + "location": "الموقع", + "customFields": "الحقول المخصصة", + "addCustomField": "إضافة حقل مخصص", + "assignedTags": "الوسوم", + "noTags": "لا توجد وسوم", + "addTag": "إضافة وسم", + "noSequences": "غير مسجل في أي تسلسل", + "sequenceStep": "الخطوة {{step}}", + "completed": "مكتمل", + "active": "نشط", + "tabInfo": "معلومات", + "tabTags": "الوسوم", + "tabSequences": "التسلسلات", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "المسارات", + "empty": "لا توجد مسارات", + "readOnlyNote": "المسارات للقراءة فقط على الجوال.", + "active": "نشط", + "inactive": "غير نشط", + "nodes": "{{count}} عقدة", + "nodes_zero": "لا توجد عقد", + "nodes_one": "عقدة واحدة", + "nodes_two": "عقدتان", + "nodes_few": "{{count}} عقد", + "nodes_many": "{{count}} عقدة", + "nodes_other": "{{count}} عقدة" + }, + "settings": { + "title": "الإعدادات", + "darkMode": "الوضع الداكن", + "theme": "المظهر", + "themeSystem": "النظام", + "themeLight": "فاتح", + "themeDark": "داكن", + "language": "اللغة", + "members": "الأعضاء", + "profile": "الملف الشخصي", + "switchWorkspace": "تبديل مساحة العمل", + "pushNotifications": "الإشعارات الفورية", + "pushNotificationsDescription": "احصل على إشعارات بالرسائل الجديدة على هذا الجهاز.", + "teams": "الفرق", + "noMembers": "لا يوجد أعضاء", + "noTeams": "لا توجد فرق", + "roleOwner": "مالك", + "roleAgent": "وكيل", + "restartRequiredTitle": "إعادة التشغيل مطلوبة", + "restartRequiredMessage": "التبديل إلى هذه اللغة يغيّر اتجاه تخطيط التطبيق. هل تريد إعادة التشغيل الآن لتطبيقه؟", + "restartManuallyMessage": "التبديل إلى هذه اللغة يغيّر اتجاه تخطيط التطبيق. أغلق التطبيق وأعد فتحه لتطبيقه.", + "restartNow": "إعادة التشغيل الآن", + "restartLater": "لاحقًا", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "الخطة" + }, + "errors": { + "unauthorized": "انتهت صلاحية جلستك. يرجى تسجيل الدخول مرة أخرى.", + "workspaceBlocked": "مساحة العمل هذه غير متاحة حاليًا.", + "workspaceBlockedTitle": "مساحة العمل غير متاحة", + "workspaceBlockedMac": "وصلت مساحة العمل هذه إلى حد جهات الاتصال في الخطة الحالية. قم بالترقية للمتابعة.", + "workspaceBlockedTrialExpired": "انتهت الفترة التجريبية أو خطة مساحة العمل هذه. قم بالترقية للمتابعة.", + "notFound": "غير موجود", + "notFoundTitle": "الصفحة غير موجودة", + "notFoundBody": "الصفحة التي تبحث عنها غير موجودة.", + "goHome": "الانتقال إلى المحادثات", + "unknown": "حدث خطأ ما. يرجى المحاولة مرة أخرى." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json new file mode 100644 index 0000000..71ec66f --- /dev/null +++ b/src/i18n/locales/da.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Indlæser…", + "error": "Noget gik galt", + "retry": "Prøv igen", + "cancel": "Annuller", + "save": "Gem", + "done": "Færdig", + "search": "Søg", + "confirm": "Bekræft", + "delete": "Slet", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Log ind", + "email": "E-mail", + "password": "Adgangskode", + "forgotPassword": "Glemt adgangskode?", + "signOut": "Log ud", + "changePassword": "Skift adgangskode", + "changePasswordRequired": "Du skal angive en ny adgangskode, før du fortsætter.", + "currentPassword": "Nuværende adgangskode", + "newPassword": "Ny adgangskode", + "sendResetLink": "Send nulstillingslink", + "resetLinkSent": "Hvis der findes en konto med denne e-mail, er der sendt et nulstillingslink.", + "orContinueWith": "Eller fortsæt med", + "continueWithGoogle": "Fortsæt med Google", + "continueWithFacebook": "Fortsæt med Facebook", + "socialSignInFailed": "Login blev ikke gennemført. Prøv igen.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspaces", + "empty": "Ingen workspaces endnu", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Samtaler", + "empty": "Ingen samtaler", + "filters": "Filtre", + "channel": "Kanal", + "handledBy": "Håndteret af", + "status": "Status", + "clearFilters": "Ryd filtre", + "filterAll": "Alle", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webchat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Ulæst", + "statusNoAdminReply": "Intet svar fra teamet", + "statusFollowUp": "Under opfølgning", + "statusArchived": "Arkiveret", + "statusBlocked": "Blokeret", + "handledByBot": "Håndteret af bot", + "handledByHuman": "Håndteret af menneske", + "unassign": "Fjern tildeling", + "noOtherMembers": "Dette workspace har endnu ingen andre medlemmer.", + "unnamed": "Unavngivet", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Skriv en besked", + "send": "Send", + "failedToSend": "Afsendelse mislykkedes", + "failedToSendTapRetry": "Afsendelse mislykkedes. Tryk for at prøve igen.", + "retrySend": "Prøv igen", + "noMessages": "Ingen beskeder endnu", + "edit": "Rediger", + "like": "Synes godt om", + "hide": "Skjul", + "delete": "Slet", + "searchSavedReplies": "Søg i gemte svar", + "noSavedReplies": "Ingen gemte svar", + "attachmentTooLarge": "{{fileName}} er større end 5 MB.", + "quotaMac": "Dette workspace har nået sin kontaktgrænse på den nuværende plan. Opgrader for at fortsætte med at sende beskeder.", + "quotaTrialExpired": "Prøveperioden eller planen for dette workspace er udløbet. Opgrader for at fortsætte med at sende beskeder.", + "quotaUnknown": "Dette workspace er i øjeblikket utilgængeligt.", + "dismiss": "Afvis", + "grantCameraPermission": "Kameraadgang er nødvendig for at tage et billede.", + "grantPermission": "Giv tilladelse", + "failedToSendPhoto": "Kunne ikke sende billedet.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Kontakter", + "contactTitle": "Kontakt", + "empty": "Ingen kontakter", + "unknown": "Ukendt", + "blocked": "Blokeret", + "email": "E-mail", + "phone": "Telefon", + "locale": "Sprog", + "timezone": "Tidszone", + "location": "Placering", + "customFields": "Brugerdefinerede felter", + "addCustomField": "Tilføj brugerdefineret felt", + "assignedTags": "Tags", + "noTags": "Ingen tags", + "addTag": "Tilføj tag", + "noSequences": "Ikke tilmeldt nogen sekvens", + "sequenceStep": "Trin {{step}}", + "completed": "Afsluttet", + "active": "Aktiv", + "tabInfo": "Info", + "tabTags": "Tags", + "tabSequences": "Sekvenser", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flows", + "empty": "Ingen flows", + "readOnlyNote": "Flows er skrivebeskyttede på mobil.", + "active": "Aktiv", + "inactive": "Inaktiv", + "nodes": "{{count}} knude", + "nodes_other": "{{count}} knuder" + }, + "settings": { + "title": "Indstillinger", + "darkMode": "Mørk tilstand", + "theme": "Tema", + "themeSystem": "System", + "themeLight": "Lys", + "themeDark": "Mørk", + "language": "Sprog", + "members": "Medlemmer", + "profile": "Profil", + "switchWorkspace": "Skift workspace", + "pushNotifications": "Push-notifikationer", + "pushNotificationsDescription": "Få besked om nye beskeder på denne enhed.", + "teams": "Teams", + "noMembers": "Ingen medlemmer", + "noTeams": "Ingen teams", + "roleOwner": "Ejer", + "roleAgent": "Agent", + "restartRequiredTitle": "Genstart påkrævet", + "restartRequiredMessage": "Skift til dette sprog ændrer appens layoutretning. Genstart nu for at anvende det?", + "restartManuallyMessage": "Skift til dette sprog ændrer appens layoutretning. Luk og genåbn appen for at anvende det.", + "restartNow": "Genstart nu", + "restartLater": "Senere", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Din session er udløbet. Log ind igen.", + "workspaceBlocked": "Dette workspace er i øjeblikket utilgængeligt.", + "workspaceBlockedTitle": "Workspace utilgængeligt", + "workspaceBlockedMac": "Dette workspace har nået sin kontaktgrænse på den nuværende plan. Opgrader for at fortsætte.", + "workspaceBlockedTrialExpired": "Prøveperioden eller planen for dette workspace er udløbet. Opgrader for at fortsætte.", + "notFound": "Ikke fundet", + "notFoundTitle": "Siden blev ikke fundet", + "notFoundBody": "Siden, du leder efter, findes ikke.", + "goHome": "Gå til samtaler", + "unknown": "Noget gik galt. Prøv igen." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json new file mode 100644 index 0000000..14afe67 --- /dev/null +++ b/src/i18n/locales/de.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Wird geladen…", + "error": "Etwas ist schiefgelaufen", + "retry": "Erneut versuchen", + "cancel": "Abbrechen", + "save": "Speichern", + "done": "Fertig", + "search": "Suchen", + "confirm": "Bestätigen", + "delete": "Löschen", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Anmelden", + "email": "E-Mail", + "password": "Passwort", + "forgotPassword": "Passwort vergessen?", + "signOut": "Abmelden", + "changePassword": "Passwort ändern", + "changePasswordRequired": "Sie müssen ein neues Passwort festlegen, bevor Sie fortfahren.", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "sendResetLink": "Link zum Zurücksetzen senden", + "resetLinkSent": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.", + "orContinueWith": "Oder fortfahren mit", + "continueWithGoogle": "Mit Google fortfahren", + "continueWithFacebook": "Mit Facebook fortfahren", + "socialSignInFailed": "Anmeldung nicht abgeschlossen. Bitte versuchen Sie es erneut.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspaces", + "empty": "Noch keine Workspaces", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Unterhaltungen", + "empty": "Keine Unterhaltungen", + "filters": "Filter", + "channel": "Kanal", + "handledBy": "Bearbeitet von", + "status": "Status", + "clearFilters": "Filter zurücksetzen", + "filterAll": "Alle", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webchat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-Mail", + "channelTiktok": "TikTok", + "statusUnread": "Ungelesen", + "statusNoAdminReply": "Keine Antwort vom Team", + "statusFollowUp": "In Nachverfolgung", + "statusArchived": "Archiviert", + "statusBlocked": "Blockiert", + "handledByBot": "Bot übernimmt", + "handledByHuman": "Mensch übernimmt", + "unassign": "Zuweisung aufheben", + "noOtherMembers": "Dieser Workspace hat noch keine weiteren Mitglieder.", + "unnamed": "Unbenannt", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Nachricht eingeben", + "send": "Senden", + "failedToSend": "Senden fehlgeschlagen", + "failedToSendTapRetry": "Senden fehlgeschlagen. Zum Wiederholen tippen.", + "retrySend": "Erneut versuchen", + "noMessages": "Noch keine Nachrichten", + "edit": "Bearbeiten", + "like": "Gefällt mir", + "hide": "Ausblenden", + "delete": "Löschen", + "searchSavedReplies": "Gespeicherte Antworten durchsuchen", + "noSavedReplies": "Keine gespeicherten Antworten", + "attachmentTooLarge": "{{fileName}} ist größer als 5 MB.", + "quotaMac": "Dieser Workspace hat sein Kontaktlimit im aktuellen Plan erreicht. Upgraden, um weiter zu senden.", + "quotaTrialExpired": "Die Testphase oder der Plan dieses Workspace ist abgelaufen. Upgraden, um weiter zu senden.", + "quotaUnknown": "Dieser Workspace ist derzeit nicht verfügbar.", + "dismiss": "Verwerfen", + "grantCameraPermission": "Für ein Foto ist Kamerazugriff erforderlich.", + "grantPermission": "Zugriff erlauben", + "failedToSendPhoto": "Foto konnte nicht gesendet werden.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Kontakte", + "contactTitle": "Kontakt", + "empty": "Keine Kontakte", + "unknown": "Unbekannt", + "blocked": "Blockiert", + "email": "E-Mail", + "phone": "Telefon", + "locale": "Sprache", + "timezone": "Zeitzone", + "location": "Standort", + "customFields": "Benutzerdefinierte Felder", + "addCustomField": "Feld hinzufügen", + "assignedTags": "Tags", + "noTags": "Keine Tags", + "addTag": "Tag hinzufügen", + "noSequences": "In keine Sequenz eingeschrieben", + "sequenceStep": "Schritt {{step}}", + "completed": "Abgeschlossen", + "active": "Aktiv", + "tabInfo": "Info", + "tabTags": "Tags", + "tabSequences": "Sequenzen", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flows", + "empty": "Keine Flows", + "readOnlyNote": "Flows sind auf Mobilgeräten schreibgeschützt.", + "active": "Aktiv", + "inactive": "Inaktiv", + "nodes": "{{count}} Knoten", + "nodes_other": "{{count}} Knoten" + }, + "settings": { + "title": "Einstellungen", + "darkMode": "Dunkelmodus", + "theme": "Design", + "themeSystem": "System", + "themeLight": "Hell", + "themeDark": "Dunkel", + "language": "Sprache", + "members": "Mitglieder", + "profile": "Profil", + "switchWorkspace": "Workspace wechseln", + "pushNotifications": "Push-Benachrichtigungen", + "pushNotificationsDescription": "Erhalten Sie Benachrichtigungen über neue Nachrichten auf diesem Gerät.", + "teams": "Teams", + "noMembers": "Keine Mitglieder", + "noTeams": "Keine Teams", + "roleOwner": "Inhaber", + "roleAgent": "Agent", + "restartRequiredTitle": "Neustart erforderlich", + "restartRequiredMessage": "Der Wechsel zu dieser Sprache ändert die Layoutrichtung der App. Jetzt neu starten, um sie anzuwenden?", + "restartManuallyMessage": "Der Wechsel zu dieser Sprache ändert die Layoutrichtung der App. Schließen und öffnen Sie die App erneut, um sie anzuwenden.", + "restartNow": "Jetzt neu starten", + "restartLater": "Später", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "workspaceBlocked": "Dieser Workspace ist derzeit nicht verfügbar.", + "workspaceBlockedTitle": "Workspace nicht verfügbar", + "workspaceBlockedMac": "Dieser Workspace hat sein Kontaktlimit im aktuellen Plan erreicht. Upgraden, um fortzufahren.", + "workspaceBlockedTrialExpired": "Die Testphase oder der Plan dieses Workspace ist abgelaufen. Upgraden, um fortzufahren.", + "notFound": "Nicht gefunden", + "notFoundTitle": "Seite nicht gefunden", + "notFoundBody": "Die gesuchte Seite existiert nicht.", + "goHome": "Zu den Unterhaltungen", + "unknown": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..15c1a07 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Loading…", + "error": "Something went wrong", + "retry": "Retry", + "cancel": "Cancel", + "save": "Save", + "done": "Done", + "search": "Search", + "confirm": "Confirm", + "delete": "Delete", + "clear": "Clear", + "close": "Close", + "back": "Back", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon" + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "auth": { + "signIn": "Sign in", + "email": "Email", + "password": "Password", + "forgotPassword": "Forgot password?", + "signOut": "Sign out", + "changePassword": "Change password", + "changePasswordRequired": "You need to set a new password before continuing.", + "currentPassword": "Current password", + "newPassword": "New password", + "sendResetLink": "Send reset link", + "resetLinkSent": "If an account exists for that email, a reset link has been sent.", + "orContinueWith": "Or continue with", + "continueWithGoogle": "Continue with Google", + "continueWithFacebook": "Continue with Facebook", + "socialSignInFailed": "Sign-in didn't complete. Please try again.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspaces", + "empty": "No workspaces yet", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversations", + "empty": "No conversations", + "filters": "Filters", + "channel": "Channel", + "handledBy": "Handled by", + "status": "Status", + "clearFilters": "Clear filters", + "filterAll": "All", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webchat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "Email", + "channelTiktok": "TikTok", + "statusUnread": "Unread", + "statusNoAdminReply": "No admin reply", + "statusFollowUp": "Following", + "statusArchived": "Archived", + "statusBlocked": "Blocked", + "handledByBot": "Bot handling", + "handledByHuman": "Human handling", + "unassign": "Unassign", + "noOtherMembers": "This workspace has no other members yet.", + "unnamed": "Unnamed", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Type a message", + "send": "Send", + "failedToSend": "Failed to send", + "failedToSendTapRetry": "Failed to send. Tap to retry.", + "retrySend": "Retry", + "noMessages": "No messages yet", + "edit": "Edit", + "like": "Like", + "hide": "Hide", + "delete": "Delete", + "searchSavedReplies": "Search saved replies", + "noSavedReplies": "No saved replies", + "attachmentTooLarge": "{{fileName}} is larger than 5MB.", + "quotaMac": "This workspace has reached its contact limit on the current plan. Upgrade to keep messaging.", + "quotaTrialExpired": "This workspace's trial or plan has expired. Upgrade to keep messaging.", + "quotaUnknown": "This workspace is currently unavailable.", + "dismiss": "Dismiss", + "grantCameraPermission": "Camera access is required to take a photo.", + "grantPermission": "Grant permission", + "failedToSendPhoto": "Failed to send photo.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + } + }, + "contacts": { + "title": "Contacts", + "contactTitle": "Contact", + "empty": "No contacts", + "unknown": "Unknown", + "blocked": "Blocked", + "email": "Email", + "phone": "Phone", + "locale": "Locale", + "timezone": "Timezone", + "location": "Location", + "customFields": "Custom fields", + "addCustomField": "Add custom field", + "assignedTags": "Tags", + "noTags": "No tags", + "addTag": "Add a tag", + "noSequences": "Not enrolled in any sequences", + "sequenceStep": "Step {{step}}", + "completed": "Completed", + "active": "Active", + "tabInfo": "Info", + "tabTags": "Tags", + "tabSequences": "Sequences", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flows", + "empty": "No flows", + "readOnlyNote": "Flows are read-only on mobile.", + "active": "Active", + "inactive": "Inactive", + "nodes": "{{count}} node", + "nodes_other": "{{count}} nodes" + }, + "settings": { + "title": "Settings", + "darkMode": "Dark mode", + "theme": "Theme", + "themeSystem": "System", + "themeLight": "Light", + "themeDark": "Dark", + "language": "Language", + "members": "Members", + "profile": "Profile", + "switchWorkspace": "Switch workspace", + "pushNotifications": "Push notifications", + "pushNotificationsDescription": "Get notified about new messages on this device.", + "teams": "Teams", + "noMembers": "No members", + "noTeams": "No teams", + "roleOwner": "Owner", + "roleAgent": "Agent", + "restartRequiredTitle": "Restart required", + "restartRequiredMessage": "Switching to this language changes the app's layout direction. Restart now to apply it?", + "restartManuallyMessage": "Switching to this language changes the app's layout direction. Close and reopen the app to apply it.", + "restartNow": "Restart now", + "restartLater": "Later", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "notifications": { + "androidChannelName": "Default" + }, + "errors": { + "unauthorized": "Your session has expired. Please sign in again.", + "workspaceBlocked": "This workspace is currently unavailable.", + "workspaceBlockedTitle": "Workspace unavailable", + "workspaceBlockedMac": "This workspace has reached its contact limit on the current plan. Upgrade to continue.", + "workspaceBlockedTrialExpired": "This workspace's trial or plan has expired. Upgrade to continue.", + "notFound": "Not found", + "notFoundTitle": "Page not found", + "notFoundBody": "The page you're looking for doesn't exist.", + "goHome": "Go to conversations", + "unknown": "Something went wrong. Please try again." + } +} diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json new file mode 100644 index 0000000..be0699b --- /dev/null +++ b/src/i18n/locales/es.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Cargando…", + "error": "Algo salió mal", + "retry": "Reintentar", + "cancel": "Cancelar", + "save": "Guardar", + "done": "Listo", + "search": "Buscar", + "confirm": "Confirmar", + "delete": "Eliminar", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Iniciar sesión", + "email": "Correo electrónico", + "password": "Contraseña", + "forgotPassword": "¿Olvidaste tu contraseña?", + "signOut": "Cerrar sesión", + "changePassword": "Cambiar contraseña", + "changePasswordRequired": "Debes establecer una nueva contraseña antes de continuar.", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "sendResetLink": "Enviar enlace de restablecimiento", + "resetLinkSent": "Si existe una cuenta con ese correo, se ha enviado un enlace de restablecimiento.", + "orContinueWith": "O continúa con", + "continueWithGoogle": "Continuar con Google", + "continueWithFacebook": "Continuar con Facebook", + "socialSignInFailed": "El inicio de sesión no se completó. Inténtalo de nuevo.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversaciones", + "empty": "No hay conversaciones", + "filters": "Filtros", + "channel": "Canal", + "handledBy": "Atendido por", + "status": "Estado", + "clearFilters": "Borrar filtros", + "filterAll": "Todos", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat web", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "Correo electrónico", + "channelTiktok": "TikTok", + "statusUnread": "No leído", + "statusNoAdminReply": "Sin respuesta del equipo", + "statusFollowUp": "En seguimiento", + "statusArchived": "Archivado", + "statusBlocked": "Bloqueado", + "handledByBot": "Atendido por el bot", + "handledByHuman": "Atendido por una persona", + "unassign": "Quitar asignación", + "noOtherMembers": "Este espacio de trabajo aún no tiene otros miembros.", + "unnamed": "Sin nombre", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Escribe un mensaje", + "send": "Enviar", + "failedToSend": "Error al enviar", + "failedToSendTapRetry": "Error al enviar. Toca para reintentar.", + "retrySend": "Reintentar", + "noMessages": "Aún no hay mensajes", + "edit": "Editar", + "like": "Me gusta", + "hide": "Ocultar", + "delete": "Eliminar", + "searchSavedReplies": "Buscar respuestas guardadas", + "noSavedReplies": "No hay respuestas guardadas", + "attachmentTooLarge": "{{fileName}} supera los 5 MB.", + "quotaMac": "Este espacio de trabajo alcanzó su límite de contactos en el plan actual. Mejora tu plan para seguir enviando mensajes.", + "quotaTrialExpired": "La prueba o el plan de este espacio de trabajo ha vencido. Mejora tu plan para seguir enviando mensajes.", + "quotaUnknown": "Este espacio de trabajo no está disponible actualmente.", + "dismiss": "Descartar", + "grantCameraPermission": "Se requiere acceso a la cámara para tomar una foto.", + "grantPermission": "Conceder permiso", + "failedToSendPhoto": "No se pudo enviar la foto.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contactos", + "contactTitle": "Contacto", + "empty": "No hay contactos", + "unknown": "Desconocido", + "blocked": "Bloqueado", + "email": "Correo electrónico", + "phone": "Teléfono", + "locale": "Idioma", + "timezone": "Zona horaria", + "location": "Ubicación", + "customFields": "Campos personalizados", + "addCustomField": "Añadir campo personalizado", + "assignedTags": "Etiquetas", + "noTags": "Sin etiquetas", + "addTag": "Añadir etiqueta", + "noSequences": "No está inscrito en ninguna secuencia", + "sequenceStep": "Paso {{step}}", + "completed": "Completado", + "active": "Activo", + "tabInfo": "Información", + "tabTags": "Etiquetas", + "tabSequences": "Secuencias", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flujos", + "empty": "No hay flujos", + "readOnlyNote": "Los flujos son de solo lectura en el móvil.", + "active": "Activo", + "inactive": "Inactivo", + "nodes": "{{count}} nodo", + "nodes_other": "{{count}} nodos" + }, + "settings": { + "title": "Configuración", + "darkMode": "Modo oscuro", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Oscuro", + "language": "Idioma", + "members": "Miembros", + "profile": "Perfil", + "switchWorkspace": "Cambiar espacio de trabajo", + "pushNotifications": "Notificaciones push", + "pushNotificationsDescription": "Recibe notificaciones de mensajes nuevos en este dispositivo.", + "teams": "Equipos", + "noMembers": "Sin miembros", + "noTeams": "Sin equipos", + "roleOwner": "Propietario", + "roleAgent": "Agente", + "restartRequiredTitle": "Reinicio requerido", + "restartRequiredMessage": "Cambiar a este idioma cambia la dirección del diseño de la app. ¿Reiniciar ahora para aplicarlo?", + "restartManuallyMessage": "Cambiar a este idioma cambia la dirección del diseño de la app. Cierra y vuelve a abrir la app para aplicarlo.", + "restartNow": "Reiniciar ahora", + "restartLater": "Más tarde", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Tu sesión ha expirado. Vuelve a iniciar sesión.", + "workspaceBlocked": "Este espacio de trabajo no está disponible actualmente.", + "workspaceBlockedTitle": "Espacio de trabajo no disponible", + "workspaceBlockedMac": "Este espacio de trabajo alcanzó su límite de contactos en el plan actual. Mejora tu plan para continuar.", + "workspaceBlockedTrialExpired": "La prueba o el plan de este espacio de trabajo ha vencido. Mejora tu plan para continuar.", + "notFound": "No encontrado", + "notFoundTitle": "Página no encontrada", + "notFoundBody": "La página que buscas no existe.", + "goHome": "Ir a conversaciones", + "unknown": "Algo salió mal. Inténtalo de nuevo." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/fi.json b/src/i18n/locales/fi.json new file mode 100644 index 0000000..be669f8 --- /dev/null +++ b/src/i18n/locales/fi.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Ladataan…", + "error": "Jokin meni pieleen", + "retry": "Yritä uudelleen", + "cancel": "Peruuta", + "save": "Tallenna", + "done": "Valmis", + "search": "Haku", + "confirm": "Vahvista", + "delete": "Poista", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Kirjaudu sisään", + "email": "Sähköposti", + "password": "Salasana", + "forgotPassword": "Unohditko salasanan?", + "signOut": "Kirjaudu ulos", + "changePassword": "Vaihda salasana", + "changePasswordRequired": "Sinun täytyy asettaa uusi salasana ennen kuin voit jatkaa.", + "currentPassword": "Nykyinen salasana", + "newPassword": "Uusi salasana", + "sendResetLink": "Lähetä palautuslinkki", + "resetLinkSent": "Jos tälle sähköpostille on tili, palautuslinkki on lähetetty.", + "orContinueWith": "Tai jatka käyttäen", + "continueWithGoogle": "Jatka Googlella", + "continueWithFacebook": "Jatka Facebookilla", + "socialSignInFailed": "Kirjautuminen ei onnistunut. Yritä uudelleen.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Työtilat", + "empty": "Ei vielä työtiloja", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Keskustelut", + "empty": "Ei keskusteluja", + "filters": "Suodattimet", + "channel": "Kanava", + "handledBy": "Käsittelijä", + "status": "Tila", + "clearFilters": "Tyhjennä suodattimet", + "filterAll": "Kaikki", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Verkkochat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "Sähköposti", + "channelTiktok": "TikTok", + "statusUnread": "Lukematon", + "statusNoAdminReply": "Ei vastausta tiimiltä", + "statusFollowUp": "Seurannassa", + "statusArchived": "Arkistoitu", + "statusBlocked": "Estetty", + "handledByBot": "Botti käsittelee", + "handledByHuman": "Ihminen käsittelee", + "unassign": "Poista vastuuhenkilö", + "noOtherMembers": "Tässä työtilassa ei vielä ole muita jäseniä.", + "unnamed": "Nimetön", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Kirjoita viesti", + "send": "Lähetä", + "failedToSend": "Lähetys epäonnistui", + "failedToSendTapRetry": "Lähetys epäonnistui. Yritä uudelleen napauttamalla.", + "retrySend": "Yritä uudelleen", + "noMessages": "Ei vielä viestejä", + "edit": "Muokkaa", + "like": "Tykkää", + "hide": "Piilota", + "delete": "Poista", + "searchSavedReplies": "Hae tallennettuja vastauksia", + "noSavedReplies": "Ei tallennettuja vastauksia", + "attachmentTooLarge": "{{fileName}} on suurempi kuin 5 Mt.", + "quotaMac": "Tämä työtila on saavuttanut nykyisen suunnitelman yhteystietorajan. Päivitä jatkaaksesi viestien lähettämistä.", + "quotaTrialExpired": "Tämän työtilan kokeilu tai suunnitelma on päättynyt. Päivitä jatkaaksesi viestien lähettämistä.", + "quotaUnknown": "Tämä työtila ei ole tällä hetkellä käytettävissä.", + "dismiss": "Hylkää", + "grantCameraPermission": "Kameran käyttöoikeus vaaditaan kuvan ottamiseen.", + "grantPermission": "Myönnä käyttöoikeus", + "failedToSendPhoto": "Kuvan lähetys epäonnistui.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Yhteystiedot", + "contactTitle": "Yhteystieto", + "empty": "Ei yhteystietoja", + "unknown": "Tuntematon", + "blocked": "Estetty", + "email": "Sähköposti", + "phone": "Puhelin", + "locale": "Kieli", + "timezone": "Aikavyöhyke", + "location": "Sijainti", + "customFields": "Mukautetut kentät", + "addCustomField": "Lisää mukautettu kenttä", + "assignedTags": "Tunnisteet", + "noTags": "Ei tunnisteita", + "addTag": "Lisää tunniste", + "noSequences": "Ei liitetty mihinkään sarjaan", + "sequenceStep": "Vaihe {{step}}", + "completed": "Valmis", + "active": "Aktiivinen", + "tabInfo": "Tiedot", + "tabTags": "Tunnisteet", + "tabSequences": "Sarjat", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flow't", + "empty": "Ei flow'ta", + "readOnlyNote": "Flow't ovat vain luku -tilassa mobiilissa.", + "active": "Aktiivinen", + "inactive": "Ei aktiivinen", + "nodes": "{{count}} solmu", + "nodes_other": "{{count}} solmua" + }, + "settings": { + "title": "Asetukset", + "darkMode": "Tumma tila", + "theme": "Teema", + "themeSystem": "Järjestelmä", + "themeLight": "Vaalea", + "themeDark": "Tumma", + "language": "Kieli", + "members": "Jäsenet", + "profile": "Profiili", + "switchWorkspace": "Vaihda työtilaa", + "pushNotifications": "Push-ilmoitukset", + "pushNotificationsDescription": "Saat ilmoituksia uusista viesteistä tällä laitteella.", + "teams": "Tiimit", + "noMembers": "Ei jäseniä", + "noTeams": "Ei tiimejä", + "roleOwner": "Omistaja", + "roleAgent": "Agentti", + "restartRequiredTitle": "Uudelleenkäynnistys vaaditaan", + "restartRequiredMessage": "Tähän kieleen vaihtaminen muuttaa sovelluksen asettelusuuntaa. Käynnistetäänkö uudelleen nyt sen ottamiseksi käyttöön?", + "restartManuallyMessage": "Tähän kieleen vaihtaminen muuttaa sovelluksen asettelusuuntaa. Sulje ja avaa sovellus uudelleen ottaaksesi sen käyttöön.", + "restartNow": "Käynnistä uudelleen nyt", + "restartLater": "Myöhemmin", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Paketti" + }, + "errors": { + "unauthorized": "Istuntosi on vanhentunut. Kirjaudu sisään uudelleen.", + "workspaceBlocked": "Tämä työtila ei ole tällä hetkellä käytettävissä.", + "workspaceBlockedTitle": "Työtila ei ole käytettävissä", + "workspaceBlockedMac": "Tämä työtila on saavuttanut nykyisen suunnitelman yhteystietorajan. Päivitä jatkaaksesi.", + "workspaceBlockedTrialExpired": "Tämän työtilan kokeilu tai suunnitelma on päättynyt. Päivitä jatkaaksesi.", + "notFound": "Ei löytynyt", + "notFoundTitle": "Sivua ei löytynyt", + "notFoundBody": "Etsimääsi sivua ei ole olemassa.", + "goHome": "Siirry keskusteluihin", + "unknown": "Jokin meni pieleen. Yritä uudelleen." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 0000000..878072a --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Chargement…", + "error": "Une erreur s'est produite", + "retry": "Réessayer", + "cancel": "Annuler", + "save": "Enregistrer", + "done": "Terminé", + "search": "Rechercher", + "confirm": "Confirmer", + "delete": "Supprimer", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Se connecter", + "email": "E-mail", + "password": "Mot de passe", + "forgotPassword": "Mot de passe oublié ?", + "signOut": "Se déconnecter", + "changePassword": "Changer le mot de passe", + "changePasswordRequired": "Vous devez définir un nouveau mot de passe avant de continuer.", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "sendResetLink": "Envoyer le lien de réinitialisation", + "resetLinkSent": "Si un compte existe pour cet e-mail, un lien de réinitialisation a été envoyé.", + "orContinueWith": "Ou continuer avec", + "continueWithGoogle": "Continuer avec Google", + "continueWithFacebook": "Continuer avec Facebook", + "socialSignInFailed": "La connexion n'a pas abouti. Veuillez réessayer.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Espaces de travail", + "empty": "Aucun espace de travail pour le moment", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversations", + "empty": "Aucune conversation", + "filters": "Filtres", + "channel": "Canal", + "handledBy": "Pris en charge par", + "status": "Statut", + "clearFilters": "Effacer les filtres", + "filterAll": "Tous", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat web", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Non lu", + "statusNoAdminReply": "Aucune réponse de l'équipe", + "statusFollowUp": "En suivi", + "statusArchived": "Archivé", + "statusBlocked": "Bloqué", + "handledByBot": "Pris en charge par le bot", + "handledByHuman": "Pris en charge par une personne", + "unassign": "Annuler l'attribution", + "noOtherMembers": "Cet espace de travail n'a pas encore d'autres membres.", + "unnamed": "Sans nom", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Écrire un message", + "send": "Envoyer", + "failedToSend": "Échec de l'envoi", + "failedToSendTapRetry": "Échec de l'envoi. Appuyez pour réessayer.", + "retrySend": "Réessayer", + "noMessages": "Aucun message pour le moment", + "edit": "Modifier", + "like": "J'aime", + "hide": "Masquer", + "delete": "Supprimer", + "searchSavedReplies": "Rechercher des réponses enregistrées", + "noSavedReplies": "Aucune réponse enregistrée", + "attachmentTooLarge": "{{fileName}} dépasse 5 Mo.", + "quotaMac": "Cet espace de travail a atteint sa limite de contacts pour le plan actuel. Passez à un plan supérieur pour continuer à envoyer des messages.", + "quotaTrialExpired": "L'essai ou le plan de cet espace de travail a expiré. Passez à un plan supérieur pour continuer à envoyer des messages.", + "quotaUnknown": "Cet espace de travail est actuellement indisponible.", + "dismiss": "Ignorer", + "grantCameraPermission": "L'accès à la caméra est requis pour prendre une photo.", + "grantPermission": "Autoriser l'accès", + "failedToSendPhoto": "Échec de l'envoi de la photo.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contacts", + "contactTitle": "Contact", + "empty": "Aucun contact", + "unknown": "Inconnu", + "blocked": "Bloqué", + "email": "E-mail", + "phone": "Téléphone", + "locale": "Langue", + "timezone": "Fuseau horaire", + "location": "Localisation", + "customFields": "Champs personnalisés", + "addCustomField": "Ajouter un champ personnalisé", + "assignedTags": "Étiquettes", + "noTags": "Aucune étiquette", + "addTag": "Ajouter une étiquette", + "noSequences": "N'est inscrit à aucune séquence", + "sequenceStep": "Étape {{step}}", + "completed": "Terminée", + "active": "Active", + "tabInfo": "Infos", + "tabTags": "Étiquettes", + "tabSequences": "Séquences", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flux", + "empty": "Aucun flux", + "readOnlyNote": "Les flux sont en lecture seule sur mobile.", + "active": "Actif", + "inactive": "Inactif", + "nodes": "{{count}} nœud", + "nodes_other": "{{count}} nœuds" + }, + "settings": { + "title": "Paramètres", + "darkMode": "Mode sombre", + "theme": "Thème", + "themeSystem": "Système", + "themeLight": "Clair", + "themeDark": "Sombre", + "language": "Langue", + "members": "Membres", + "profile": "Profil", + "switchWorkspace": "Changer d'espace de travail", + "pushNotifications": "Notifications push", + "pushNotificationsDescription": "Recevez des notifications pour les nouveaux messages sur cet appareil.", + "teams": "Équipes", + "noMembers": "Aucun membre", + "noTeams": "Aucune équipe", + "roleOwner": "Propriétaire", + "roleAgent": "Agent", + "restartRequiredTitle": "Redémarrage requis", + "restartRequiredMessage": "Passer à cette langue change le sens de la mise en page de l'application. Redémarrer maintenant pour l'appliquer ?", + "restartManuallyMessage": "Passer à cette langue change le sens de la mise en page de l'application. Fermez et rouvrez l'application pour l'appliquer.", + "restartNow": "Redémarrer maintenant", + "restartLater": "Plus tard", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Forfait" + }, + "errors": { + "unauthorized": "Votre session a expiré. Veuillez vous reconnecter.", + "workspaceBlocked": "Cet espace de travail est actuellement indisponible.", + "workspaceBlockedTitle": "Espace de travail indisponible", + "workspaceBlockedMac": "Cet espace de travail a atteint sa limite de contacts pour le plan actuel. Passez à un plan supérieur pour continuer.", + "workspaceBlockedTrialExpired": "L'essai ou le plan de cet espace de travail a expiré. Passez à un plan supérieur pour continuer.", + "notFound": "Introuvable", + "notFoundTitle": "Page introuvable", + "notFoundBody": "La page que vous recherchez n'existe pas.", + "goHome": "Aller aux conversations", + "unknown": "Une erreur s'est produite. Veuillez réessayer." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json new file mode 100644 index 0000000..e859882 --- /dev/null +++ b/src/i18n/locales/he.json @@ -0,0 +1,261 @@ +{ + "common": { + "loading": "טוען…", + "error": "משהו השתבש", + "retry": "נסה שוב", + "cancel": "ביטול", + "save": "שמירה", + "done": "בוצע", + "search": "חיפוש", + "confirm": "אישור", + "delete": "מחיקה", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "התחברות", + "email": "אימייל", + "password": "סיסמה", + "forgotPassword": "שכחת סיסמה?", + "signOut": "התנתקות", + "changePassword": "שינוי סיסמה", + "changePasswordRequired": "עליך להגדיר סיסמה חדשה לפני שתוכל להמשיך.", + "currentPassword": "סיסמה נוכחית", + "newPassword": "סיסמה חדשה", + "sendResetLink": "שליחת קישור לאיפוס", + "resetLinkSent": "אם קיים חשבון עבור אימייל זה, נשלח קישור לאיפוס.", + "orContinueWith": "או המשך עם", + "continueWithGoogle": "המשך עם Google", + "continueWithFacebook": "המשך עם Facebook", + "socialSignInFailed": "ההתחברות לא הושלמה. נסה שוב.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "סביבות עבודה", + "empty": "אין עדיין סביבות עבודה", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "שיחות", + "empty": "אין שיחות", + "filters": "מסננים", + "channel": "ערוץ", + "handledBy": "מטופלת על ידי", + "status": "סטטוס", + "clearFilters": "ניקוי מסננים", + "filterAll": "הכול", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "צ'אט באתר", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "אימייל", + "channelTiktok": "TikTok", + "statusUnread": "לא נקראה", + "statusNoAdminReply": "אין תגובה מהצוות", + "statusFollowUp": "במעקב", + "statusArchived": "בארכיון", + "statusBlocked": "חסומה", + "handledByBot": "מטופלת על ידי בוט", + "handledByHuman": "מטופלת על ידי אדם", + "unassign": "ביטול שיוך", + "noOtherMembers": "לסביבת עבודה זו אין עדיין חברים נוספים.", + "unnamed": "ללא שם", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "הקלד הודעה", + "send": "שליחה", + "failedToSend": "השליחה נכשלה", + "failedToSendTapRetry": "השליחה נכשלה. הקש כדי לנסות שוב.", + "retrySend": "נסה שוב", + "noMessages": "אין עדיין הודעות", + "edit": "עריכה", + "like": "לייק", + "hide": "הסתרה", + "delete": "מחיקה", + "searchSavedReplies": "חיפוש תגובות שמורות", + "noSavedReplies": "אין תגובות שמורות", + "attachmentTooLarge": "{{fileName}} גדול מ-5MB.", + "quotaMac": "סביבת עבודה זו הגיעה למגבלת אנשי הקשר בתוכנית הנוכחית. שדרג כדי להמשיך לשלוח הודעות.", + "quotaTrialExpired": "תקופת הניסיון או התוכנית של סביבת עבודה זו פגה. שדרג כדי להמשיך לשלוח הודעות.", + "quotaUnknown": "סביבת עבודה זו אינה זמינה כרגע.", + "dismiss": "סגירה", + "grantCameraPermission": "נדרשת גישה למצלמה כדי לצלם תמונה.", + "grantPermission": "הענקת הרשאה", + "failedToSendPhoto": "שליחת התמונה נכשלה.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "אנשי קשר", + "contactTitle": "איש קשר", + "empty": "אין אנשי קשר", + "unknown": "לא ידוע", + "blocked": "חסום", + "email": "אימייל", + "phone": "טלפון", + "locale": "שפה", + "timezone": "אזור זמן", + "location": "מיקום", + "customFields": "שדות מותאמים אישית", + "addCustomField": "הוספת שדה מותאם אישית", + "assignedTags": "תגיות", + "noTags": "אין תגיות", + "addTag": "הוספת תגית", + "noSequences": "לא רשום לאף רצף", + "sequenceStep": "שלב {{step}}", + "completed": "הושלם", + "active": "פעיל", + "tabInfo": "מידע", + "tabTags": "תגיות", + "tabSequences": "רצפים", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "תהליכים", + "empty": "אין תהליכים", + "readOnlyNote": "תהליכים הם לקריאה בלבד בנייד.", + "active": "פעיל", + "inactive": "לא פעיל", + "nodes": "{{count}} צומת", + "nodes_one": "צומת אחד", + "nodes_two": "שני צמתים", + "nodes_other": "{{count}} צמתים" + }, + "settings": { + "title": "הגדרות", + "darkMode": "מצב כהה", + "theme": "ערכת נושא", + "themeSystem": "מערכת", + "themeLight": "בהיר", + "themeDark": "כהה", + "language": "שפה", + "members": "חברים", + "profile": "פרופיל", + "switchWorkspace": "החלפת סביבת עבודה", + "pushNotifications": "התראות דחיפה", + "pushNotificationsDescription": "קבל התראות על הודעות חדשות במכשיר זה.", + "teams": "צוותים", + "noMembers": "אין חברים", + "noTeams": "אין צוותים", + "roleOwner": "בעלים", + "roleAgent": "נציג", + "restartRequiredTitle": "נדרשת הפעלה מחדש", + "restartRequiredMessage": "מעבר לשפה זו משנה את כיוון הפריסה של האפליקציה. להפעיל מחדש עכשיו כדי להחיל אותה?", + "restartManuallyMessage": "מעבר לשפה זו משנה את כיוון הפריסה של האפליקציה. סגור ופתח מחדש את האפליקציה כדי להחיל אותה.", + "restartNow": "הפעל מחדש עכשיו", + "restartLater": "מאוחר יותר", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "תוכנית" + }, + "errors": { + "unauthorized": "פג תוקף ההפעלה שלך. אנא התחבר שוב.", + "workspaceBlocked": "סביבת עבודה זו אינה זמינה כרגע.", + "workspaceBlockedTitle": "סביבת עבודה אינה זמינה", + "workspaceBlockedMac": "סביבת עבודה זו הגיעה למגבלת אנשי הקשר בתוכנית הנוכחית. שדרג כדי להמשיך.", + "workspaceBlockedTrialExpired": "תקופת הניסיון או התוכנית של סביבת עבודה זו פגה. שדרג כדי להמשיך.", + "notFound": "לא נמצא", + "notFoundTitle": "הדף לא נמצא", + "notFoundBody": "הדף שאתה מחפש אינו קיים.", + "goHome": "מעבר לשיחות", + "unknown": "משהו השתבש. נסה שוב." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json new file mode 100644 index 0000000..de5fd76 --- /dev/null +++ b/src/i18n/locales/id.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Memuat…", + "error": "Terjadi kesalahan", + "retry": "Coba lagi", + "cancel": "Batal", + "save": "Simpan", + "done": "Selesai", + "search": "Cari", + "confirm": "Konfirmasi", + "delete": "Hapus", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Masuk", + "email": "Email", + "password": "Kata sandi", + "forgotPassword": "Lupa kata sandi?", + "signOut": "Keluar", + "changePassword": "Ubah kata sandi", + "changePasswordRequired": "Anda perlu membuat kata sandi baru sebelum melanjutkan.", + "currentPassword": "Kata sandi saat ini", + "newPassword": "Kata sandi baru", + "sendResetLink": "Kirim tautan reset", + "resetLinkSent": "Jika akun dengan email ini ada, tautan reset telah dikirim.", + "orContinueWith": "Atau lanjutkan dengan", + "continueWithGoogle": "Lanjutkan dengan Google", + "continueWithFacebook": "Lanjutkan dengan Facebook", + "socialSignInFailed": "Masuk tidak berhasil. Silakan coba lagi.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Ruang kerja", + "empty": "Belum ada ruang kerja", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Percakapan", + "empty": "Tidak ada percakapan", + "filters": "Filter", + "channel": "Saluran", + "handledBy": "Ditangani oleh", + "status": "Status", + "clearFilters": "Hapus filter", + "filterAll": "Semua", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Obrolan web", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "Email", + "channelTiktok": "TikTok", + "statusUnread": "Belum dibaca", + "statusNoAdminReply": "Belum ada balasan dari tim", + "statusFollowUp": "Sedang ditindaklanjuti", + "statusArchived": "Diarsipkan", + "statusBlocked": "Diblokir", + "handledByBot": "Ditangani oleh bot", + "handledByHuman": "Ditangani oleh manusia", + "unassign": "Batalkan penugasan", + "noOtherMembers": "Ruang kerja ini belum memiliki anggota lain.", + "unnamed": "Tanpa nama", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Ketik pesan", + "send": "Kirim", + "failedToSend": "Gagal mengirim", + "failedToSendTapRetry": "Gagal mengirim. Ketuk untuk mencoba lagi.", + "retrySend": "Coba lagi", + "noMessages": "Belum ada pesan", + "edit": "Edit", + "like": "Suka", + "hide": "Sembunyikan", + "delete": "Hapus", + "searchSavedReplies": "Cari balasan tersimpan", + "noSavedReplies": "Tidak ada balasan tersimpan", + "attachmentTooLarge": "{{fileName}} lebih besar dari 5MB.", + "quotaMac": "Ruang kerja ini telah mencapai batas kontak pada paket saat ini. Tingkatkan paket untuk terus mengirim pesan.", + "quotaTrialExpired": "Masa percobaan atau paket ruang kerja ini telah berakhir. Tingkatkan paket untuk terus mengirim pesan.", + "quotaUnknown": "Ruang kerja ini saat ini tidak tersedia.", + "dismiss": "Tutup", + "grantCameraPermission": "Akses kamera diperlukan untuk mengambil foto.", + "grantPermission": "Berikan izin", + "failedToSendPhoto": "Gagal mengirim foto.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Kontak", + "contactTitle": "Kontak", + "empty": "Tidak ada kontak", + "unknown": "Tidak diketahui", + "blocked": "Diblokir", + "email": "Email", + "phone": "Telepon", + "locale": "Bahasa", + "timezone": "Zona waktu", + "location": "Lokasi", + "customFields": "Bidang khusus", + "addCustomField": "Tambahkan bidang khusus", + "assignedTags": "Tag", + "noTags": "Tidak ada tag", + "addTag": "Tambahkan tag", + "noSequences": "Tidak terdaftar dalam sekuens apa pun", + "sequenceStep": "Langkah {{step}}", + "completed": "Selesai", + "active": "Aktif", + "tabInfo": "Info", + "tabTags": "Tag", + "tabSequences": "Sekuens", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Alur", + "empty": "Tidak ada alur", + "readOnlyNote": "Alur bersifat hanya-baca di perangkat seluler.", + "active": "Aktif", + "inactive": "Tidak aktif", + "nodes": "{{count}} node", + "nodes_other": "{{count}} node" + }, + "settings": { + "title": "Pengaturan", + "darkMode": "Mode gelap", + "theme": "Tema", + "themeSystem": "Sistem", + "themeLight": "Terang", + "themeDark": "Gelap", + "language": "Bahasa", + "members": "Anggota", + "profile": "Profil", + "switchWorkspace": "Ganti ruang kerja", + "pushNotifications": "Notifikasi push", + "pushNotificationsDescription": "Dapatkan notifikasi tentang pesan baru di perangkat ini.", + "teams": "Tim", + "noMembers": "Tidak ada anggota", + "noTeams": "Tidak ada tim", + "roleOwner": "Pemilik", + "roleAgent": "Agen", + "restartRequiredTitle": "Perlu memulai ulang", + "restartRequiredMessage": "Beralih ke bahasa ini mengubah arah tata letak aplikasi. Mulai ulang sekarang untuk menerapkannya?", + "restartManuallyMessage": "Beralih ke bahasa ini mengubah arah tata letak aplikasi. Tutup dan buka kembali aplikasi untuk menerapkannya.", + "restartNow": "Mulai ulang sekarang", + "restartLater": "Nanti", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Paket" + }, + "errors": { + "unauthorized": "Sesi Anda telah berakhir. Silakan masuk kembali.", + "workspaceBlocked": "Ruang kerja ini saat ini tidak tersedia.", + "workspaceBlockedTitle": "Ruang kerja tidak tersedia", + "workspaceBlockedMac": "Ruang kerja ini telah mencapai batas kontak pada paket saat ini. Tingkatkan paket untuk melanjutkan.", + "workspaceBlockedTrialExpired": "Masa percobaan atau paket ruang kerja ini telah berakhir. Tingkatkan paket untuk melanjutkan.", + "notFound": "Tidak ditemukan", + "notFoundTitle": "Halaman tidak ditemukan", + "notFoundBody": "Halaman yang Anda cari tidak ada.", + "goHome": "Ke percakapan", + "unknown": "Terjadi kesalahan. Silakan coba lagi." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json new file mode 100644 index 0000000..6f3725a --- /dev/null +++ b/src/i18n/locales/it.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Caricamento…", + "error": "Qualcosa è andato storto", + "retry": "Riprova", + "cancel": "Annulla", + "save": "Salva", + "done": "Fatto", + "search": "Cerca", + "confirm": "Conferma", + "delete": "Elimina", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Accedi", + "email": "E-mail", + "password": "Password", + "forgotPassword": "Password dimenticata?", + "signOut": "Esci", + "changePassword": "Cambia password", + "changePasswordRequired": "Devi impostare una nuova password prima di continuare.", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", + "sendResetLink": "Invia link di reimpostazione", + "resetLinkSent": "Se esiste un account con questa e-mail, è stato inviato un link di reimpostazione.", + "orContinueWith": "Oppure continua con", + "continueWithGoogle": "Continua con Google", + "continueWithFacebook": "Continua con Facebook", + "socialSignInFailed": "Accesso non riuscito. Riprova.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspace", + "empty": "Nessun workspace ancora", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversazioni", + "empty": "Nessuna conversazione", + "filters": "Filtri", + "channel": "Canale", + "handledBy": "Gestita da", + "status": "Stato", + "clearFilters": "Cancella filtri", + "filterAll": "Tutti", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat web", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Non letto", + "statusNoAdminReply": "Nessuna risposta del team", + "statusFollowUp": "In monitoraggio", + "statusArchived": "Archiviata", + "statusBlocked": "Bloccata", + "handledByBot": "Gestita dal bot", + "handledByHuman": "Gestita da una persona", + "unassign": "Rimuovi assegnazione", + "noOtherMembers": "Questo workspace non ha ancora altri membri.", + "unnamed": "Senza nome", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Scrivi un messaggio", + "send": "Invia", + "failedToSend": "Invio non riuscito", + "failedToSendTapRetry": "Invio non riuscito. Tocca per riprovare.", + "retrySend": "Riprova", + "noMessages": "Nessun messaggio ancora", + "edit": "Modifica", + "like": "Mi piace", + "hide": "Nascondi", + "delete": "Elimina", + "searchSavedReplies": "Cerca risposte salvate", + "noSavedReplies": "Nessuna risposta salvata", + "attachmentTooLarge": "{{fileName}} supera i 5 MB.", + "quotaMac": "Questo workspace ha raggiunto il limite di contatti del piano attuale. Esegui l'upgrade per continuare a inviare messaggi.", + "quotaTrialExpired": "La prova o il piano di questo workspace è scaduto. Esegui l'upgrade per continuare a inviare messaggi.", + "quotaUnknown": "Questo workspace non è attualmente disponibile.", + "dismiss": "Ignora", + "grantCameraPermission": "È richiesto l'accesso alla fotocamera per scattare una foto.", + "grantPermission": "Concedi il permesso", + "failedToSendPhoto": "Invio della foto non riuscito.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contatti", + "contactTitle": "Contatto", + "empty": "Nessun contatto", + "unknown": "Sconosciuto", + "blocked": "Bloccato", + "email": "E-mail", + "phone": "Telefono", + "locale": "Lingua", + "timezone": "Fuso orario", + "location": "Posizione", + "customFields": "Campi personalizzati", + "addCustomField": "Aggiungi campo personalizzato", + "assignedTags": "Tag", + "noTags": "Nessun tag", + "addTag": "Aggiungi tag", + "noSequences": "Non iscritto ad alcuna sequenza", + "sequenceStep": "Passaggio {{step}}", + "completed": "Completata", + "active": "Attiva", + "tabInfo": "Info", + "tabTags": "Tag", + "tabSequences": "Sequenze", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flussi", + "empty": "Nessun flusso", + "readOnlyNote": "I flussi sono di sola lettura su mobile.", + "active": "Attivo", + "inactive": "Inattivo", + "nodes": "{{count}} nodo", + "nodes_other": "{{count}} nodi" + }, + "settings": { + "title": "Impostazioni", + "darkMode": "Modalità scura", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Chiaro", + "themeDark": "Scuro", + "language": "Lingua", + "members": "Membri", + "profile": "Profilo", + "switchWorkspace": "Cambia workspace", + "pushNotifications": "Notifiche push", + "pushNotificationsDescription": "Ricevi notifiche per i nuovi messaggi su questo dispositivo.", + "teams": "Team", + "noMembers": "Nessun membro", + "noTeams": "Nessun team", + "roleOwner": "Proprietario", + "roleAgent": "Agente", + "restartRequiredTitle": "Riavvio richiesto", + "restartRequiredMessage": "Passare a questa lingua cambia la direzione del layout dell'app. Riavviare ora per applicarla?", + "restartManuallyMessage": "Passare a questa lingua cambia la direzione del layout dell'app. Chiudi e riapri l'app per applicarla.", + "restartNow": "Riavvia ora", + "restartLater": "Più tardi", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Piano" + }, + "errors": { + "unauthorized": "La sessione è scaduta. Accedi di nuovo.", + "workspaceBlocked": "Questo workspace non è attualmente disponibile.", + "workspaceBlockedTitle": "Workspace non disponibile", + "workspaceBlockedMac": "Questo workspace ha raggiunto il limite di contatti del piano attuale. Esegui l'upgrade per continuare.", + "workspaceBlockedTrialExpired": "La prova o il piano di questo workspace è scaduto. Esegui l'upgrade per continuare.", + "notFound": "Non trovato", + "notFoundTitle": "Pagina non trovata", + "notFoundBody": "La pagina cercata non esiste.", + "goHome": "Vai alle conversazioni", + "unknown": "Qualcosa è andato storto. Riprova." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json new file mode 100644 index 0000000..64bcf17 --- /dev/null +++ b/src/i18n/locales/ja.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "読み込み中…", + "error": "問題が発生しました", + "retry": "再試行", + "cancel": "キャンセル", + "save": "保存", + "done": "完了", + "search": "検索", + "confirm": "確認", + "delete": "削除", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "サインイン", + "email": "メールアドレス", + "password": "パスワード", + "forgotPassword": "パスワードをお忘れですか?", + "signOut": "サインアウト", + "changePassword": "パスワードを変更", + "changePasswordRequired": "続行する前に新しいパスワードを設定してください。", + "currentPassword": "現在のパスワード", + "newPassword": "新しいパスワード", + "sendResetLink": "リセットリンクを送信", + "resetLinkSent": "このメールアドレスのアカウントが存在する場合、リセットリンクが送信されました。", + "orContinueWith": "または次で続行", + "continueWithGoogle": "Googleで続行", + "continueWithFacebook": "Facebookで続行", + "socialSignInFailed": "サインインが完了しませんでした。もう一度お試しください。", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "ワークスペース", + "empty": "ワークスペースはまだありません", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "会話", + "empty": "会話がありません", + "filters": "フィルター", + "channel": "チャネル", + "handledBy": "対応者", + "status": "ステータス", + "clearFilters": "フィルターをクリア", + "filterAll": "すべて", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "ウェブチャット", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "メール", + "channelTiktok": "TikTok", + "statusUnread": "未読", + "statusNoAdminReply": "チームからの返信なし", + "statusFollowUp": "フォローアップ中", + "statusArchived": "アーカイブ済み", + "statusBlocked": "ブロック済み", + "handledByBot": "ボットが対応中", + "handledByHuman": "担当者が対応中", + "unassign": "割り当て解除", + "noOtherMembers": "このワークスペースには他のメンバーがまだいません。", + "unnamed": "名前なし", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "メッセージを入力", + "send": "送信", + "failedToSend": "送信に失敗しました", + "failedToSendTapRetry": "送信に失敗しました。タップして再試行してください。", + "retrySend": "再試行", + "noMessages": "メッセージはまだありません", + "edit": "編集", + "like": "いいね", + "hide": "非表示", + "delete": "削除", + "searchSavedReplies": "保存済み返信を検索", + "noSavedReplies": "保存済み返信がありません", + "attachmentTooLarge": "{{fileName}} は5MBを超えています。", + "quotaMac": "このワークスペースは現在のプランの連絡先上限に達しました。メッセージの送信を続けるにはアップグレードしてください。", + "quotaTrialExpired": "このワークスペースの試用期間またはプランが終了しました。メッセージの送信を続けるにはアップグレードしてください。", + "quotaUnknown": "このワークスペースは現在利用できません。", + "dismiss": "閉じる", + "grantCameraPermission": "写真を撮るにはカメラへのアクセスが必要です。", + "grantPermission": "許可する", + "failedToSendPhoto": "写真の送信に失敗しました。", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "連絡先", + "contactTitle": "連絡先", + "empty": "連絡先がありません", + "unknown": "不明", + "blocked": "ブロック済み", + "email": "メール", + "phone": "電話番号", + "locale": "言語", + "timezone": "タイムゾーン", + "location": "所在地", + "customFields": "カスタムフィールド", + "addCustomField": "カスタムフィールドを追加", + "assignedTags": "タグ", + "noTags": "タグなし", + "addTag": "タグを追加", + "noSequences": "どのシーケンスにも登録されていません", + "sequenceStep": "ステップ {{step}}", + "completed": "完了", + "active": "有効", + "tabInfo": "情報", + "tabTags": "タグ", + "tabSequences": "シーケンス", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "フロー", + "empty": "フローがありません", + "readOnlyNote": "フローはモバイルでは読み取り専用です。", + "active": "有効", + "inactive": "無効", + "nodes": "{{count}} 個のノード", + "nodes_other": "{{count}} 個のノード" + }, + "settings": { + "title": "設定", + "darkMode": "ダークモード", + "theme": "テーマ", + "themeSystem": "システム", + "themeLight": "ライト", + "themeDark": "ダーク", + "language": "言語", + "members": "メンバー", + "profile": "プロフィール", + "switchWorkspace": "ワークスペースを切り替え", + "pushNotifications": "プッシュ通知", + "pushNotificationsDescription": "このデバイスで新しいメッセージの通知を受け取ります。", + "teams": "チーム", + "noMembers": "メンバーがいません", + "noTeams": "チームがありません", + "roleOwner": "オーナー", + "roleAgent": "エージェント", + "restartRequiredTitle": "再起動が必要です", + "restartRequiredMessage": "この言語に切り替えると、アプリのレイアウトの方向が変わります。今すぐ再起動して適用しますか?", + "restartManuallyMessage": "この言語に切り替えると、アプリのレイアウトの方向が変わります。アプリを閉じて再度開くと適用されます。", + "restartNow": "今すぐ再起動", + "restartLater": "後で", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "プラン" + }, + "errors": { + "unauthorized": "セッションの有効期限が切れました。再度サインインしてください。", + "workspaceBlocked": "このワークスペースは現在利用できません。", + "workspaceBlockedTitle": "ワークスペースを利用できません", + "workspaceBlockedMac": "このワークスペースは現在のプランの連絡先上限に達しました。続けるにはアップグレードしてください。", + "workspaceBlockedTrialExpired": "このワークスペースの試用期間またはプランが終了しました。続けるにはアップグレードしてください。", + "notFound": "見つかりません", + "notFoundTitle": "ページが見つかりません", + "notFoundBody": "お探しのページは存在しません。", + "goHome": "会話に戻る", + "unknown": "問題が発生しました。もう一度お試しください。" + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json new file mode 100644 index 0000000..f4227ec --- /dev/null +++ b/src/i18n/locales/nl.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Laden…", + "error": "Er is iets misgegaan", + "retry": "Opnieuw proberen", + "cancel": "Annuleren", + "save": "Opslaan", + "done": "Klaar", + "search": "Zoeken", + "confirm": "Bevestigen", + "delete": "Verwijderen", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Inloggen", + "email": "E-mail", + "password": "Wachtwoord", + "forgotPassword": "Wachtwoord vergeten?", + "signOut": "Uitloggen", + "changePassword": "Wachtwoord wijzigen", + "changePasswordRequired": "Je moet een nieuw wachtwoord instellen voordat je verdergaat.", + "currentPassword": "Huidig wachtwoord", + "newPassword": "Nieuw wachtwoord", + "sendResetLink": "Reset-link versturen", + "resetLinkSent": "Als er een account bestaat voor dit e-mailadres, is er een reset-link verzonden.", + "orContinueWith": "Of ga verder met", + "continueWithGoogle": "Doorgaan met Google", + "continueWithFacebook": "Doorgaan met Facebook", + "socialSignInFailed": "Inloggen niet voltooid. Probeer het opnieuw.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspaces", + "empty": "Nog geen workspaces", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Gesprekken", + "empty": "Geen gesprekken", + "filters": "Filters", + "channel": "Kanaal", + "handledBy": "Behandeld door", + "status": "Status", + "clearFilters": "Filters wissen", + "filterAll": "Alle", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webchat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Ongelezen", + "statusNoAdminReply": "Geen reactie van het team", + "statusFollowUp": "In opvolging", + "statusArchived": "Gearchiveerd", + "statusBlocked": "Geblokkeerd", + "handledByBot": "Behandeld door bot", + "handledByHuman": "Behandeld door mens", + "unassign": "Toewijzing opheffen", + "noOtherMembers": "Deze workspace heeft nog geen andere leden.", + "unnamed": "Naamloos", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Typ een bericht", + "send": "Verstuur", + "failedToSend": "Verzenden mislukt", + "failedToSendTapRetry": "Verzenden mislukt. Tik om opnieuw te proberen.", + "retrySend": "Opnieuw proberen", + "noMessages": "Nog geen berichten", + "edit": "Bewerken", + "like": "Vind ik leuk", + "hide": "Verbergen", + "delete": "Verwijderen", + "searchSavedReplies": "Opgeslagen antwoorden zoeken", + "noSavedReplies": "Geen opgeslagen antwoorden", + "attachmentTooLarge": "{{fileName}} is groter dan 5 MB.", + "quotaMac": "Deze workspace heeft de contactlimiet van het huidige plan bereikt. Upgrade om te blijven berichten sturen.", + "quotaTrialExpired": "De proefperiode of het plan van deze workspace is verlopen. Upgrade om te blijven berichten sturen.", + "quotaUnknown": "Deze workspace is momenteel niet beschikbaar.", + "dismiss": "Sluiten", + "grantCameraPermission": "Cameratoegang is vereist om een foto te maken.", + "grantPermission": "Toegang verlenen", + "failedToSendPhoto": "Foto verzenden mislukt.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contacten", + "contactTitle": "Contact", + "empty": "Geen contacten", + "unknown": "Onbekend", + "blocked": "Geblokkeerd", + "email": "E-mail", + "phone": "Telefoon", + "locale": "Taal", + "timezone": "Tijdzone", + "location": "Locatie", + "customFields": "Aangepaste velden", + "addCustomField": "Aangepast veld toevoegen", + "assignedTags": "Tags", + "noTags": "Geen tags", + "addTag": "Tag toevoegen", + "noSequences": "Niet ingeschreven voor een reeks", + "sequenceStep": "Stap {{step}}", + "completed": "Voltooid", + "active": "Actief", + "tabInfo": "Info", + "tabTags": "Tags", + "tabSequences": "Reeksen", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flows", + "empty": "Geen flows", + "readOnlyNote": "Flows zijn alleen-lezen op mobiel.", + "active": "Actief", + "inactive": "Inactief", + "nodes": "{{count}} node", + "nodes_other": "{{count}} nodes" + }, + "settings": { + "title": "Instellingen", + "darkMode": "Donkere modus", + "theme": "Thema", + "themeSystem": "Systeem", + "themeLight": "Licht", + "themeDark": "Donker", + "language": "Taal", + "members": "Leden", + "profile": "Profiel", + "switchWorkspace": "Workspace wisselen", + "pushNotifications": "Pushmeldingen", + "pushNotificationsDescription": "Ontvang meldingen voor nieuwe berichten op dit apparaat.", + "teams": "Teams", + "noMembers": "Geen leden", + "noTeams": "Geen teams", + "roleOwner": "Eigenaar", + "roleAgent": "Agent", + "restartRequiredTitle": "Herstart vereist", + "restartRequiredMessage": "Overschakelen naar deze taal verandert de lay-outrichting van de app. Nu herstarten om toe te passen?", + "restartManuallyMessage": "Overschakelen naar deze taal verandert de lay-outrichting van de app. Sluit en open de app opnieuw om toe te passen.", + "restartNow": "Nu herstarten", + "restartLater": "Later", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Abonnement" + }, + "errors": { + "unauthorized": "Je sessie is verlopen. Log opnieuw in.", + "workspaceBlocked": "Deze workspace is momenteel niet beschikbaar.", + "workspaceBlockedTitle": "Workspace niet beschikbaar", + "workspaceBlockedMac": "Deze workspace heeft de contactlimiet van het huidige plan bereikt. Upgrade om door te gaan.", + "workspaceBlockedTrialExpired": "De proefperiode of het plan van deze workspace is verlopen. Upgrade om door te gaan.", + "notFound": "Niet gevonden", + "notFoundTitle": "Pagina niet gevonden", + "notFoundBody": "De pagina die je zoekt bestaat niet.", + "goHome": "Naar gesprekken", + "unknown": "Er is iets misgegaan. Probeer het opnieuw." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json new file mode 100644 index 0000000..fe8cc78 --- /dev/null +++ b/src/i18n/locales/pt-BR.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Carregando…", + "error": "Algo deu errado", + "retry": "Tentar novamente", + "cancel": "Cancelar", + "save": "Salvar", + "done": "Concluído", + "search": "Buscar", + "confirm": "Confirmar", + "delete": "Excluir", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Entrar", + "email": "E-mail", + "password": "Senha", + "forgotPassword": "Esqueceu a senha?", + "signOut": "Sair", + "changePassword": "Alterar senha", + "changePasswordRequired": "Você precisa definir uma nova senha antes de continuar.", + "currentPassword": "Senha atual", + "newPassword": "Nova senha", + "sendResetLink": "Enviar link de redefinição", + "resetLinkSent": "Se existir uma conta com esse e-mail, um link de redefinição foi enviado.", + "orContinueWith": "Ou continue com", + "continueWithGoogle": "Continuar com o Google", + "continueWithFacebook": "Continuar com o Facebook", + "socialSignInFailed": "O login não foi concluído. Tente novamente.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Workspaces", + "empty": "Ainda não há workspaces", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversas", + "empty": "Nenhuma conversa", + "filters": "Filtros", + "channel": "Canal", + "handledBy": "Atendida por", + "status": "Status", + "clearFilters": "Limpar filtros", + "filterAll": "Todos", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat do site", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Não lida", + "statusNoAdminReply": "Sem resposta do time", + "statusFollowUp": "Em acompanhamento", + "statusArchived": "Arquivada", + "statusBlocked": "Bloqueada", + "handledByBot": "Atendida pelo bot", + "handledByHuman": "Atendida por uma pessoa", + "unassign": "Remover atribuição", + "noOtherMembers": "Este workspace ainda não tem outros membros.", + "unnamed": "Sem nome", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Digite uma mensagem", + "send": "Enviar", + "failedToSend": "Falha ao enviar", + "failedToSendTapRetry": "Falha ao enviar. Toque para tentar novamente.", + "retrySend": "Tentar novamente", + "noMessages": "Ainda não há mensagens", + "edit": "Editar", + "like": "Curtir", + "hide": "Ocultar", + "delete": "Excluir", + "searchSavedReplies": "Buscar respostas salvas", + "noSavedReplies": "Nenhuma resposta salva", + "attachmentTooLarge": "{{fileName}} tem mais de 5 MB.", + "quotaMac": "Este workspace atingiu o limite de contatos do plano atual. Faça upgrade para continuar enviando mensagens.", + "quotaTrialExpired": "O período de teste ou o plano deste workspace expirou. Faça upgrade para continuar enviando mensagens.", + "quotaUnknown": "Este workspace está indisponível no momento.", + "dismiss": "Dispensar", + "grantCameraPermission": "É necessário acesso à câmera para tirar uma foto.", + "grantPermission": "Conceder permissão", + "failedToSendPhoto": "Falha ao enviar a foto.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contatos", + "contactTitle": "Contato", + "empty": "Nenhum contato", + "unknown": "Desconhecido", + "blocked": "Bloqueado", + "email": "E-mail", + "phone": "Telefone", + "locale": "Idioma", + "timezone": "Fuso horário", + "location": "Localização", + "customFields": "Campos personalizados", + "addCustomField": "Adicionar campo personalizado", + "assignedTags": "Tags", + "noTags": "Sem tags", + "addTag": "Adicionar tag", + "noSequences": "Não inscrito em nenhuma sequência", + "sequenceStep": "Etapa {{step}}", + "completed": "Concluída", + "active": "Ativa", + "tabInfo": "Info", + "tabTags": "Tags", + "tabSequences": "Sequências", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Fluxos", + "empty": "Nenhum fluxo", + "readOnlyNote": "Os fluxos são somente leitura no celular.", + "active": "Ativo", + "inactive": "Inativo", + "nodes": "{{count}} nó", + "nodes_other": "{{count}} nós" + }, + "settings": { + "title": "Configurações", + "darkMode": "Modo escuro", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "language": "Idioma", + "members": "Membros", + "profile": "Perfil", + "switchWorkspace": "Trocar workspace", + "pushNotifications": "Notificações push", + "pushNotificationsDescription": "Receba notificações de novas mensagens neste dispositivo.", + "teams": "Times", + "noMembers": "Nenhum membro", + "noTeams": "Nenhum time", + "roleOwner": "Proprietário", + "roleAgent": "Agente", + "restartRequiredTitle": "Reinício necessário", + "restartRequiredMessage": "Mudar para este idioma altera a direção do layout do app. Reiniciar agora para aplicar?", + "restartManuallyMessage": "Mudar para este idioma altera a direção do layout do app. Feche e reabra o app para aplicar.", + "restartNow": "Reiniciar agora", + "restartLater": "Mais tarde", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plano" + }, + "errors": { + "unauthorized": "Sua sessão expirou. Faça login novamente.", + "workspaceBlocked": "Este workspace está indisponível no momento.", + "workspaceBlockedTitle": "Workspace indisponível", + "workspaceBlockedMac": "Este workspace atingiu o limite de contatos do plano atual. Faça upgrade para continuar.", + "workspaceBlockedTrialExpired": "O período de teste ou o plano deste workspace expirou. Faça upgrade para continuar.", + "notFound": "Não encontrado", + "notFoundTitle": "Página não encontrada", + "notFoundBody": "A página que você procura não existe.", + "goHome": "Ir para conversas", + "unknown": "Algo deu errado. Tente novamente." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json new file mode 100644 index 0000000..7065e01 --- /dev/null +++ b/src/i18n/locales/pt-PT.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "A carregar…", + "error": "Algo correu mal", + "retry": "Tentar novamente", + "cancel": "Cancelar", + "save": "Guardar", + "done": "Concluído", + "search": "Pesquisar", + "confirm": "Confirmar", + "delete": "Eliminar", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Iniciar sessão", + "email": "E-mail", + "password": "Palavra-passe", + "forgotPassword": "Esqueceu-se da palavra-passe?", + "signOut": "Terminar sessão", + "changePassword": "Alterar palavra-passe", + "changePasswordRequired": "Tem de definir uma nova palavra-passe antes de continuar.", + "currentPassword": "Palavra-passe atual", + "newPassword": "Nova palavra-passe", + "sendResetLink": "Enviar link de reposição", + "resetLinkSent": "Se existir uma conta com este e-mail, foi enviado um link de reposição.", + "orContinueWith": "Ou continue com", + "continueWithGoogle": "Continuar com o Google", + "continueWithFacebook": "Continuar com o Facebook", + "socialSignInFailed": "O início de sessão não foi concluído. Tente novamente.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Espaços de trabalho", + "empty": "Ainda não existem espaços de trabalho", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversas", + "empty": "Sem conversas", + "filters": "Filtros", + "channel": "Canal", + "handledBy": "Atendida por", + "status": "Estado", + "clearFilters": "Limpar filtros", + "filterAll": "Todas", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat do site", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Não lida", + "statusNoAdminReply": "Sem resposta da equipa", + "statusFollowUp": "Em acompanhamento", + "statusArchived": "Arquivada", + "statusBlocked": "Bloqueada", + "handledByBot": "Atendida pelo bot", + "handledByHuman": "Atendida por uma pessoa", + "unassign": "Remover atribuição", + "noOtherMembers": "Este espaço de trabalho ainda não tem outros membros.", + "unnamed": "Sem nome", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Escreva uma mensagem", + "send": "Enviar", + "failedToSend": "Falha ao enviar", + "failedToSendTapRetry": "Falha ao enviar. Toque para tentar novamente.", + "retrySend": "Tentar novamente", + "noMessages": "Ainda não existem mensagens", + "edit": "Editar", + "like": "Gosto", + "hide": "Ocultar", + "delete": "Eliminar", + "searchSavedReplies": "Pesquisar respostas guardadas", + "noSavedReplies": "Sem respostas guardadas", + "attachmentTooLarge": "{{fileName}} tem mais de 5 MB.", + "quotaMac": "Este espaço de trabalho atingiu o limite de contactos do plano atual. Faça upgrade para continuar a enviar mensagens.", + "quotaTrialExpired": "O período de teste ou o plano deste espaço de trabalho expirou. Faça upgrade para continuar a enviar mensagens.", + "quotaUnknown": "Este espaço de trabalho está indisponível de momento.", + "dismiss": "Dispensar", + "grantCameraPermission": "É necessário acesso à câmara para tirar uma fotografia.", + "grantPermission": "Conceder permissão", + "failedToSendPhoto": "Falha ao enviar a fotografia.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contactos", + "contactTitle": "Contacto", + "empty": "Sem contactos", + "unknown": "Desconhecido", + "blocked": "Bloqueado", + "email": "E-mail", + "phone": "Telefone", + "locale": "Idioma", + "timezone": "Fuso horário", + "location": "Localização", + "customFields": "Campos personalizados", + "addCustomField": "Adicionar campo personalizado", + "assignedTags": "Etiquetas", + "noTags": "Sem etiquetas", + "addTag": "Adicionar etiqueta", + "noSequences": "Não inscrito em nenhuma sequência", + "sequenceStep": "Passo {{step}}", + "completed": "Concluída", + "active": "Ativa", + "tabInfo": "Info", + "tabTags": "Etiquetas", + "tabSequences": "Sequências", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Fluxos", + "empty": "Sem fluxos", + "readOnlyNote": "Os fluxos são apenas de leitura no telemóvel.", + "active": "Ativo", + "inactive": "Inativo", + "nodes": "{{count}} nó", + "nodes_other": "{{count}} nós" + }, + "settings": { + "title": "Definições", + "darkMode": "Modo escuro", + "theme": "Tema", + "themeSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "language": "Idioma", + "members": "Membros", + "profile": "Perfil", + "switchWorkspace": "Mudar de espaço de trabalho", + "pushNotifications": "Notificações push", + "pushNotificationsDescription": "Receba notificações de novas mensagens neste dispositivo.", + "teams": "Equipas", + "noMembers": "Sem membros", + "noTeams": "Sem equipas", + "roleOwner": "Proprietário", + "roleAgent": "Agente", + "restartRequiredTitle": "Reinício necessário", + "restartRequiredMessage": "Mudar para este idioma altera a direção do esquema da aplicação. Reiniciar agora para aplicar?", + "restartManuallyMessage": "Mudar para este idioma altera a direção do esquema da aplicação. Feche e reabra a aplicação para aplicar.", + "restartNow": "Reiniciar agora", + "restartLater": "Mais tarde", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plano" + }, + "errors": { + "unauthorized": "A sua sessão expirou. Inicie sessão novamente.", + "workspaceBlocked": "Este espaço de trabalho está indisponível de momento.", + "workspaceBlockedTitle": "Espaço de trabalho indisponível", + "workspaceBlockedMac": "Este espaço de trabalho atingiu o limite de contactos do plano atual. Faça upgrade para continuar.", + "workspaceBlockedTrialExpired": "O período de teste ou o plano deste espaço de trabalho expirou. Faça upgrade para continuar.", + "notFound": "Não encontrado", + "notFoundTitle": "Página não encontrada", + "notFoundBody": "A página que procura não existe.", + "goHome": "Ir para as conversas", + "unknown": "Algo correu mal. Tente novamente." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/ro.json b/src/i18n/locales/ro.json new file mode 100644 index 0000000..4a55005 --- /dev/null +++ b/src/i18n/locales/ro.json @@ -0,0 +1,260 @@ +{ + "common": { + "loading": "Se încarcă…", + "error": "Ceva nu a mers bine", + "retry": "Reîncearcă", + "cancel": "Anulează", + "save": "Salvează", + "done": "Gata", + "search": "Caută", + "confirm": "Confirmă", + "delete": "Șterge", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Conectare", + "email": "E-mail", + "password": "Parolă", + "forgotPassword": "Ai uitat parola?", + "signOut": "Deconectare", + "changePassword": "Schimbă parola", + "changePasswordRequired": "Trebuie să setezi o parolă nouă înainte de a continua.", + "currentPassword": "Parola curentă", + "newPassword": "Parolă nouă", + "sendResetLink": "Trimite link de resetare", + "resetLinkSent": "Dacă există un cont pentru acest e-mail, a fost trimis un link de resetare.", + "orContinueWith": "Sau continuă cu", + "continueWithGoogle": "Continuă cu Google", + "continueWithFacebook": "Continuă cu Facebook", + "socialSignInFailed": "Conectarea nu s-a finalizat. Te rugăm să încerci din nou.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Spații de lucru", + "empty": "Niciun spațiu de lucru încă", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Conversații", + "empty": "Nicio conversație", + "filters": "Filtre", + "channel": "Canal", + "handledBy": "Gestionată de", + "status": "Stare", + "clearFilters": "Șterge filtrele", + "filterAll": "Toate", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Chat web", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-mail", + "channelTiktok": "TikTok", + "statusUnread": "Necitit", + "statusNoAdminReply": "Fără răspuns din partea echipei", + "statusFollowUp": "În urmărire", + "statusArchived": "Arhivată", + "statusBlocked": "Blocată", + "handledByBot": "Gestionată de bot", + "handledByHuman": "Gestionată de o persoană", + "unassign": "Anulează atribuirea", + "noOtherMembers": "Acest spațiu de lucru nu are încă alți membri.", + "unnamed": "Fără nume", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Scrie un mesaj", + "send": "Trimite", + "failedToSend": "Trimiterea a eșuat", + "failedToSendTapRetry": "Trimiterea a eșuat. Atinge pentru a reîncerca.", + "retrySend": "Reîncearcă", + "noMessages": "Niciun mesaj încă", + "edit": "Editează", + "like": "Apreciază", + "hide": "Ascunde", + "delete": "Șterge", + "searchSavedReplies": "Caută răspunsuri salvate", + "noSavedReplies": "Niciun răspuns salvat", + "attachmentTooLarge": "{{fileName}} depășește 5 MB.", + "quotaMac": "Acest spațiu de lucru a atins limita de contacte a planului curent. Fă upgrade pentru a continua să trimiți mesaje.", + "quotaTrialExpired": "Perioada de probă sau planul acestui spațiu de lucru a expirat. Fă upgrade pentru a continua să trimiți mesaje.", + "quotaUnknown": "Acest spațiu de lucru nu este disponibil momentan.", + "dismiss": "Închide", + "grantCameraPermission": "Este necesar accesul la cameră pentru a face o fotografie.", + "grantPermission": "Acordă permisiune", + "failedToSendPhoto": "Trimiterea fotografiei a eșuat.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Contacte", + "contactTitle": "Contact", + "empty": "Niciun contact", + "unknown": "Necunoscut", + "blocked": "Blocat", + "email": "E-mail", + "phone": "Telefon", + "locale": "Limbă", + "timezone": "Fus orar", + "location": "Locație", + "customFields": "Câmpuri personalizate", + "addCustomField": "Adaugă câmp personalizat", + "assignedTags": "Etichete", + "noTags": "Fără etichete", + "addTag": "Adaugă etichetă", + "noSequences": "Nu este înscris în nicio secvență", + "sequenceStep": "Pasul {{step}}", + "completed": "Finalizată", + "active": "Activă", + "tabInfo": "Info", + "tabTags": "Etichete", + "tabSequences": "Secvențe", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Fluxuri", + "empty": "Niciun flux", + "readOnlyNote": "Fluxurile sunt needitabile pe mobil.", + "active": "Activ", + "inactive": "Inactiv", + "nodes": "{{count}} nod", + "nodes_few": "{{count}} noduri", + "nodes_other": "{{count}} de noduri" + }, + "settings": { + "title": "Setări", + "darkMode": "Mod întunecat", + "theme": "Temă", + "themeSystem": "Sistem", + "themeLight": "Deschisă", + "themeDark": "Întunecată", + "language": "Limbă", + "members": "Membri", + "profile": "Profil", + "switchWorkspace": "Schimbă spațiul de lucru", + "pushNotifications": "Notificări push", + "pushNotificationsDescription": "Primești notificări despre mesajele noi pe acest dispozitiv.", + "teams": "Echipe", + "noMembers": "Niciun membru", + "noTeams": "Nicio echipă", + "roleOwner": "Proprietar", + "roleAgent": "Agent", + "restartRequiredTitle": "Este necesară repornirea", + "restartRequiredMessage": "Trecerea la această limbă schimbă direcția aspectului aplicației. Repornești acum pentru a o aplica?", + "restartManuallyMessage": "Trecerea la această limbă schimbă direcția aspectului aplicației. Închide și redeschide aplicația pentru a o aplica.", + "restartNow": "Repornește acum", + "restartLater": "Mai târziu", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Sesiunea ta a expirat. Te rugăm să te conectezi din nou.", + "workspaceBlocked": "Acest spațiu de lucru nu este disponibil momentan.", + "workspaceBlockedTitle": "Spațiu de lucru indisponibil", + "workspaceBlockedMac": "Acest spațiu de lucru a atins limita de contacte a planului curent. Fă upgrade pentru a continua.", + "workspaceBlockedTrialExpired": "Perioada de probă sau planul acestui spațiu de lucru a expirat. Fă upgrade pentru a continua.", + "notFound": "Negăsit", + "notFoundTitle": "Pagina nu a fost găsită", + "notFoundBody": "Pagina pe care o cauți nu există.", + "goHome": "Mergi la conversații", + "unknown": "Ceva nu a mers bine. Te rugăm să încerci din nou." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json new file mode 100644 index 0000000..ac9f12f --- /dev/null +++ b/src/i18n/locales/sv.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Läser in…", + "error": "Något gick fel", + "retry": "Försök igen", + "cancel": "Avbryt", + "save": "Spara", + "done": "Klar", + "search": "Sök", + "confirm": "Bekräfta", + "delete": "Ta bort", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Logga in", + "email": "E-post", + "password": "Lösenord", + "forgotPassword": "Glömt lösenordet?", + "signOut": "Logga ut", + "changePassword": "Byt lösenord", + "changePasswordRequired": "Du måste ange ett nytt lösenord innan du fortsätter.", + "currentPassword": "Nuvarande lösenord", + "newPassword": "Nytt lösenord", + "sendResetLink": "Skicka återställningslänk", + "resetLinkSent": "Om ett konto finns för den e-postadressen har en återställningslänk skickats.", + "orContinueWith": "Eller fortsätt med", + "continueWithGoogle": "Fortsätt med Google", + "continueWithFacebook": "Fortsätt med Facebook", + "socialSignInFailed": "Inloggningen slutfördes inte. Försök igen.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Arbetsytor", + "empty": "Inga arbetsytor än", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Konversationer", + "empty": "Inga konversationer", + "filters": "Filter", + "channel": "Kanal", + "handledBy": "Hanteras av", + "status": "Status", + "clearFilters": "Rensa filter", + "filterAll": "Alla", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webbchatt", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-post", + "channelTiktok": "TikTok", + "statusUnread": "Oläst", + "statusNoAdminReply": "Inget svar från teamet", + "statusFollowUp": "Under uppföljning", + "statusArchived": "Arkiverad", + "statusBlocked": "Blockerad", + "handledByBot": "Hanteras av bot", + "handledByHuman": "Hanteras av person", + "unassign": "Ta bort tilldelning", + "noOtherMembers": "Den här arbetsytan har inga andra medlemmar än.", + "unnamed": "Namnlös", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Skriv ett meddelande", + "send": "Skicka", + "failedToSend": "Det gick inte att skicka", + "failedToSendTapRetry": "Det gick inte att skicka. Tryck för att försöka igen.", + "retrySend": "Försök igen", + "noMessages": "Inga meddelanden än", + "edit": "Redigera", + "like": "Gilla", + "hide": "Dölj", + "delete": "Ta bort", + "searchSavedReplies": "Sök sparade svar", + "noSavedReplies": "Inga sparade svar", + "attachmentTooLarge": "{{fileName}} är större än 5 MB.", + "quotaMac": "Den här arbetsytan har nått sin kontaktgräns på den aktuella planen. Uppgradera för att fortsätta skicka meddelanden.", + "quotaTrialExpired": "Provperioden eller planen för den här arbetsytan har gått ut. Uppgradera för att fortsätta skicka meddelanden.", + "quotaUnknown": "Den här arbetsytan är för tillfället inte tillgänglig.", + "dismiss": "Avfärda", + "grantCameraPermission": "Kameraåtkomst krävs för att ta en bild.", + "grantPermission": "Ge behörighet", + "failedToSendPhoto": "Det gick inte att skicka bilden.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Kontakter", + "contactTitle": "Kontakt", + "empty": "Inga kontakter", + "unknown": "Okänd", + "blocked": "Blockerad", + "email": "E-post", + "phone": "Telefon", + "locale": "Språk", + "timezone": "Tidszon", + "location": "Plats", + "customFields": "Anpassade fält", + "addCustomField": "Lägg till anpassat fält", + "assignedTags": "Taggar", + "noTags": "Inga taggar", + "addTag": "Lägg till tagg", + "noSequences": "Inte inskriven i någon sekvens", + "sequenceStep": "Steg {{step}}", + "completed": "Avslutad", + "active": "Aktiv", + "tabInfo": "Info", + "tabTags": "Taggar", + "tabSequences": "Sekvenser", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Flöden", + "empty": "Inga flöden", + "readOnlyNote": "Flöden är skrivskyddade på mobil.", + "active": "Aktiv", + "inactive": "Inaktiv", + "nodes": "{{count}} nod", + "nodes_other": "{{count}} noder" + }, + "settings": { + "title": "Inställningar", + "darkMode": "Mörkt läge", + "theme": "Tema", + "themeSystem": "System", + "themeLight": "Ljust", + "themeDark": "Mörkt", + "language": "Språk", + "members": "Medlemmar", + "profile": "Profil", + "switchWorkspace": "Byt arbetsyta", + "pushNotifications": "Push-notiser", + "pushNotificationsDescription": "Få aviseringar om nya meddelanden på den här enheten.", + "teams": "Team", + "noMembers": "Inga medlemmar", + "noTeams": "Inga team", + "roleOwner": "Ägare", + "roleAgent": "Agent", + "restartRequiredTitle": "Omstart krävs", + "restartRequiredMessage": "Att byta till det här språket ändrar appens layoutriktning. Starta om nu för att tillämpa det?", + "restartManuallyMessage": "Att byta till det här språket ändrar appens layoutriktning. Stäng och öppna appen igen för att tillämpa det.", + "restartNow": "Starta om nu", + "restartLater": "Senare", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Din session har gått ut. Logga in igen.", + "workspaceBlocked": "Den här arbetsytan är för tillfället inte tillgänglig.", + "workspaceBlockedTitle": "Arbetsytan är inte tillgänglig", + "workspaceBlockedMac": "Den här arbetsytan har nått sin kontaktgräns på den aktuella planen. Uppgradera för att fortsätta.", + "workspaceBlockedTrialExpired": "Provperioden eller planen för den här arbetsytan har gått ut. Uppgradera för att fortsätta.", + "notFound": "Hittades inte", + "notFoundTitle": "Sidan hittades inte", + "notFoundBody": "Sidan du letar efter finns inte.", + "goHome": "Gå till konversationer", + "unknown": "Något gick fel. Försök igen." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json new file mode 100644 index 0000000..044c4a2 --- /dev/null +++ b/src/i18n/locales/tr.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Yükleniyor…", + "error": "Bir şeyler ters gitti", + "retry": "Tekrar dene", + "cancel": "İptal", + "save": "Kaydet", + "done": "Tamam", + "search": "Ara", + "confirm": "Onayla", + "delete": "Sil", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Giriş yap", + "email": "E-posta", + "password": "Şifre", + "forgotPassword": "Şifrenizi mi unuttunuz?", + "signOut": "Çıkış yap", + "changePassword": "Şifreyi değiştir", + "changePasswordRequired": "Devam etmeden önce yeni bir şifre belirlemeniz gerekiyor.", + "currentPassword": "Mevcut şifre", + "newPassword": "Yeni şifre", + "sendResetLink": "Sıfırlama bağlantısı gönder", + "resetLinkSent": "Bu e-posta için bir hesap varsa, sıfırlama bağlantısı gönderildi.", + "orContinueWith": "Veya şununla devam et", + "continueWithGoogle": "Google ile devam et", + "continueWithFacebook": "Facebook ile devam et", + "socialSignInFailed": "Giriş tamamlanamadı. Lütfen tekrar deneyin.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Çalışma alanları", + "empty": "Henüz çalışma alanı yok", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Konuşmalar", + "empty": "Konuşma yok", + "filters": "Filtreler", + "channel": "Kanal", + "handledBy": "İşleyen", + "status": "Durum", + "clearFilters": "Filtreleri temizle", + "filterAll": "Tümü", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Web sohbeti", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "E-posta", + "channelTiktok": "TikTok", + "statusUnread": "Okunmadı", + "statusNoAdminReply": "Ekipten yanıt yok", + "statusFollowUp": "Takipte", + "statusArchived": "Arşivlendi", + "statusBlocked": "Engellendi", + "handledByBot": "Bot işliyor", + "handledByHuman": "Kişi işliyor", + "unassign": "Atamayı kaldır", + "noOtherMembers": "Bu çalışma alanında henüz başka üye yok.", + "unnamed": "İsimsiz", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Bir mesaj yazın", + "send": "Gönder", + "failedToSend": "Gönderilemedi", + "failedToSendTapRetry": "Gönderilemedi. Yeniden denemek için dokunun.", + "retrySend": "Tekrar dene", + "noMessages": "Henüz mesaj yok", + "edit": "Düzenle", + "like": "Beğen", + "hide": "Gizle", + "delete": "Sil", + "searchSavedReplies": "Kayıtlı yanıtlarda ara", + "noSavedReplies": "Kayıtlı yanıt yok", + "attachmentTooLarge": "{{fileName}} 5 MB'tan büyük.", + "quotaMac": "Bu çalışma alanı mevcut plandaki kişi sınırına ulaştı. Mesajlaşmaya devam etmek için yükseltin.", + "quotaTrialExpired": "Bu çalışma alanının denemesi veya planı sona erdi. Mesajlaşmaya devam etmek için yükseltin.", + "quotaUnknown": "Bu çalışma alanı şu anda kullanılamıyor.", + "dismiss": "Kapat", + "grantCameraPermission": "Fotoğraf çekmek için kamera erişimi gerekiyor.", + "grantPermission": "İzin ver", + "failedToSendPhoto": "Fotoğraf gönderilemedi.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Kişiler", + "contactTitle": "Kişi", + "empty": "Kişi yok", + "unknown": "Bilinmiyor", + "blocked": "Engellendi", + "email": "E-posta", + "phone": "Telefon", + "locale": "Dil", + "timezone": "Saat dilimi", + "location": "Konum", + "customFields": "Özel alanlar", + "addCustomField": "Özel alan ekle", + "assignedTags": "Etiketler", + "noTags": "Etiket yok", + "addTag": "Etiket ekle", + "noSequences": "Herhangi bir diziye kayıtlı değil", + "sequenceStep": "Adım {{step}}", + "completed": "Tamamlandı", + "active": "Aktif", + "tabInfo": "Bilgi", + "tabTags": "Etiketler", + "tabSequences": "Diziler", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Akışlar", + "empty": "Akış yok", + "readOnlyNote": "Akışlar mobilde salt okunurdur.", + "active": "Aktif", + "inactive": "Pasif", + "nodes": "{{count}} düğüm", + "nodes_other": "{{count}} düğüm" + }, + "settings": { + "title": "Ayarlar", + "darkMode": "Karanlık mod", + "theme": "Tema", + "themeSystem": "Sistem", + "themeLight": "Açık", + "themeDark": "Koyu", + "language": "Dil", + "members": "Üyeler", + "profile": "Profil", + "switchWorkspace": "Çalışma alanını değiştir", + "pushNotifications": "Push bildirimleri", + "pushNotificationsDescription": "Bu cihazda yeni mesajlar hakkında bildirim alın.", + "teams": "Ekipler", + "noMembers": "Üye yok", + "noTeams": "Ekip yok", + "roleOwner": "Sahip", + "roleAgent": "Temsilci", + "restartRequiredTitle": "Yeniden başlatma gerekli", + "restartRequiredMessage": "Bu dile geçmek uygulamanın düzen yönünü değiştirir. Uygulamak için şimdi yeniden başlatılsın mı?", + "restartManuallyMessage": "Bu dile geçmek uygulamanın düzen yönünü değiştirir. Uygulamak için uygulamayı kapatıp yeniden açın.", + "restartNow": "Şimdi yeniden başlat", + "restartLater": "Daha sonra", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Plan" + }, + "errors": { + "unauthorized": "Oturumunuzun süresi doldu. Lütfen tekrar giriş yapın.", + "workspaceBlocked": "Bu çalışma alanı şu anda kullanılamıyor.", + "workspaceBlockedTitle": "Çalışma alanı kullanılamıyor", + "workspaceBlockedMac": "Bu çalışma alanı mevcut plandaki kişi sınırına ulaştı. Devam etmek için yükseltin.", + "workspaceBlockedTrialExpired": "Bu çalışma alanının denemesi veya planı sona erdi. Devam etmek için yükseltin.", + "notFound": "Bulunamadı", + "notFoundTitle": "Sayfa bulunamadı", + "notFoundBody": "Aradığınız sayfa mevcut değil.", + "goHome": "Konuşmalara git", + "unknown": "Bir şeyler ters gitti. Lütfen tekrar deneyin." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json new file mode 100644 index 0000000..7c48d47 --- /dev/null +++ b/src/i18n/locales/vi.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "Đang tải…", + "error": "Đã xảy ra lỗi", + "retry": "Thử lại", + "cancel": "Hủy", + "save": "Lưu", + "done": "Xong", + "search": "Tìm kiếm", + "confirm": "Xác nhận", + "delete": "Xóa", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "Đăng nhập", + "email": "Email", + "password": "Mật khẩu", + "forgotPassword": "Quên mật khẩu?", + "signOut": "Đăng xuất", + "changePassword": "Đổi mật khẩu", + "changePasswordRequired": "Bạn cần đặt mật khẩu mới trước khi tiếp tục.", + "currentPassword": "Mật khẩu hiện tại", + "newPassword": "Mật khẩu mới", + "sendResetLink": "Gửi liên kết đặt lại", + "resetLinkSent": "Nếu email tồn tại, liên kết đặt lại mật khẩu đã được gửi.", + "orContinueWith": "Hoặc tiếp tục với", + "continueWithGoogle": "Tiếp tục với Google", + "continueWithFacebook": "Tiếp tục với Facebook", + "socialSignInFailed": "Đăng nhập chưa hoàn tất. Vui lòng thử lại.", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "Không gian làm việc", + "empty": "Chưa có không gian làm việc nào", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "Hội thoại", + "empty": "Không có hội thoại", + "filters": "Bộ lọc", + "channel": "Kênh", + "handledBy": "Xử lý bởi", + "status": "Trạng thái", + "clearFilters": "Xóa bộ lọc", + "filterAll": "Tất cả", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "Webchat", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "Email", + "channelTiktok": "TikTok", + "statusUnread": "Chưa đọc", + "statusNoAdminReply": "Chưa có phản hồi từ quản trị", + "statusFollowUp": "Đang theo dõi", + "statusArchived": "Đã lưu trữ", + "statusBlocked": "Đã chặn", + "handledByBot": "Bot xử lý", + "handledByHuman": "Người xử lý", + "unassign": "Bỏ phân công", + "noOtherMembers": "Không gian làm việc này chưa có thành viên nào khác.", + "unnamed": "Chưa đặt tên", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "Nhập tin nhắn", + "send": "Gửi", + "failedToSend": "Gửi thất bại", + "failedToSendTapRetry": "Gửi thất bại. Nhấn để thử lại.", + "retrySend": "Thử lại", + "noMessages": "Chưa có tin nhắn", + "edit": "Sửa", + "like": "Thích", + "hide": "Ẩn", + "delete": "Xóa", + "searchSavedReplies": "Tìm mẫu trả lời nhanh", + "noSavedReplies": "Không có mẫu trả lời nhanh", + "attachmentTooLarge": "{{fileName}} lớn hơn 5MB.", + "quotaMac": "Không gian làm việc này đã đạt giới hạn liên hệ của gói hiện tại. Nâng cấp để tiếp tục nhắn tin.", + "quotaTrialExpired": "Gói dùng thử hoặc gói hiện tại của không gian làm việc này đã hết hạn. Nâng cấp để tiếp tục nhắn tin.", + "quotaUnknown": "Không gian làm việc này hiện không khả dụng.", + "dismiss": "Đóng", + "grantCameraPermission": "Cần quyền truy cập máy ảnh để chụp ảnh.", + "grantPermission": "Cấp quyền", + "failedToSendPhoto": "Gửi ảnh thất bại.", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "Liên hệ", + "contactTitle": "Liên hệ", + "empty": "Không có liên hệ", + "unknown": "Không xác định", + "blocked": "Đã chặn", + "email": "Email", + "phone": "Số điện thoại", + "locale": "Ngôn ngữ", + "timezone": "Múi giờ", + "location": "Vị trí", + "customFields": "Trường tùy chỉnh", + "addCustomField": "Thêm trường tùy chỉnh", + "assignedTags": "Nhãn", + "noTags": "Không có nhãn", + "addTag": "Thêm nhãn", + "noSequences": "Chưa tham gia chuỗi kịch bản nào", + "sequenceStep": "Bước {{step}}", + "completed": "Hoàn thành", + "active": "Đang hoạt động", + "tabInfo": "Thông tin", + "tabTags": "Nhãn", + "tabSequences": "Chuỗi kịch bản", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "Kịch bản", + "empty": "Không có kịch bản", + "readOnlyNote": "Kịch bản chỉ xem trên thiết bị di động.", + "active": "Đang bật", + "inactive": "Đang tắt", + "nodes": "{{count}} nút", + "nodes_other": "{{count}} nút" + }, + "settings": { + "title": "Cài đặt", + "darkMode": "Chế độ tối", + "theme": "Giao diện", + "themeSystem": "Hệ thống", + "themeLight": "Sáng", + "themeDark": "Tối", + "language": "Ngôn ngữ", + "members": "Thành viên", + "profile": "Hồ sơ", + "switchWorkspace": "Đổi không gian làm việc", + "pushNotifications": "Thông báo đẩy", + "pushNotificationsDescription": "Nhận thông báo về tin nhắn mới trên thiết bị này.", + "teams": "Nhóm", + "noMembers": "Không có thành viên", + "noTeams": "Không có nhóm", + "roleOwner": "Chủ sở hữu", + "roleAgent": "Nhân viên", + "restartRequiredTitle": "Cần khởi động lại", + "restartRequiredMessage": "Chuyển sang ngôn ngữ này sẽ thay đổi hướng bố cục của ứng dụng. Khởi động lại ngay để áp dụng?", + "restartManuallyMessage": "Chuyển sang ngôn ngữ này sẽ thay đổi hướng bố cục của ứng dụng. Hãy đóng và mở lại ứng dụng để áp dụng.", + "restartNow": "Khởi động lại ngay", + "restartLater": "Để sau", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "Gói" + }, + "errors": { + "unauthorized": "Phiên làm việc đã hết hạn. Vui lòng đăng nhập lại.", + "workspaceBlocked": "Không gian làm việc này hiện không khả dụng.", + "workspaceBlockedTitle": "Không gian làm việc không khả dụng", + "workspaceBlockedMac": "Không gian làm việc này đã đạt giới hạn liên hệ của gói hiện tại. Nâng cấp để tiếp tục.", + "workspaceBlockedTrialExpired": "Gói dùng thử hoặc gói hiện tại của không gian làm việc này đã hết hạn. Nâng cấp để tiếp tục.", + "notFound": "Không tìm thấy", + "notFoundTitle": "Không tìm thấy trang", + "notFoundBody": "Trang bạn tìm không tồn tại.", + "goHome": "Về trang hội thoại", + "unknown": "Đã xảy ra lỗi. Vui lòng thử lại." + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..76dd444 --- /dev/null +++ b/src/i18n/locales/zh-CN.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "加载中…", + "error": "出错了", + "retry": "重试", + "cancel": "取消", + "save": "保存", + "done": "完成", + "search": "搜索", + "confirm": "确认", + "delete": "删除", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "登录", + "email": "邮箱", + "password": "密码", + "forgotPassword": "忘记密码?", + "signOut": "退出登录", + "changePassword": "修改密码", + "changePasswordRequired": "继续之前需要设置新密码。", + "currentPassword": "当前密码", + "newPassword": "新密码", + "sendResetLink": "发送重置链接", + "resetLinkSent": "如果该邮箱存在账号,重置链接已发送。", + "orContinueWith": "或使用以下方式继续", + "continueWithGoogle": "使用 Google 继续", + "continueWithFacebook": "使用 Facebook 继续", + "socialSignInFailed": "登录未完成,请重试。", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "工作区", + "empty": "暂无工作区", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "对话", + "empty": "暂无对话", + "filters": "筛选", + "channel": "渠道", + "handledBy": "处理人", + "status": "状态", + "clearFilters": "清除筛选", + "filterAll": "全部", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "网页聊天", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "邮箱", + "channelTiktok": "TikTok", + "statusUnread": "未读", + "statusNoAdminReply": "团队未回复", + "statusFollowUp": "跟进中", + "statusArchived": "已归档", + "statusBlocked": "已屏蔽", + "handledByBot": "机器人处理中", + "handledByHuman": "人工处理中", + "unassign": "取消分配", + "noOtherMembers": "此工作区还没有其他成员。", + "unnamed": "未命名", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "输入消息", + "send": "发送", + "failedToSend": "发送失败", + "failedToSendTapRetry": "发送失败,点击重试。", + "retrySend": "重试", + "noMessages": "暂无消息", + "edit": "编辑", + "like": "点赞", + "hide": "隐藏", + "delete": "删除", + "searchSavedReplies": "搜索保存的回复", + "noSavedReplies": "没有保存的回复", + "attachmentTooLarge": "{{fileName}} 超过 5MB。", + "quotaMac": "此工作区已达到当前套餐的联系人上限,请升级以继续发送消息。", + "quotaTrialExpired": "此工作区的试用期或套餐已过期,请升级以继续发送消息。", + "quotaUnknown": "此工作区当前不可用。", + "dismiss": "关闭", + "grantCameraPermission": "拍照需要相机权限。", + "grantPermission": "授予权限", + "failedToSendPhoto": "照片发送失败。", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "联系人", + "contactTitle": "联系人", + "empty": "暂无联系人", + "unknown": "未知", + "blocked": "已屏蔽", + "email": "邮箱", + "phone": "电话", + "locale": "语言", + "timezone": "时区", + "location": "位置", + "customFields": "自定义字段", + "addCustomField": "添加自定义字段", + "assignedTags": "标签", + "noTags": "暂无标签", + "addTag": "添加标签", + "noSequences": "未加入任何序列", + "sequenceStep": "第 {{step}} 步", + "completed": "已完成", + "active": "进行中", + "tabInfo": "信息", + "tabTags": "标签", + "tabSequences": "序列", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "流程", + "empty": "暂无流程", + "readOnlyNote": "流程在移动端为只读。", + "active": "已启用", + "inactive": "未启用", + "nodes": "{{count}} 个节点", + "nodes_other": "{{count}} 个节点" + }, + "settings": { + "title": "设置", + "darkMode": "深色模式", + "theme": "主题", + "themeSystem": "跟随系统", + "themeLight": "浅色", + "themeDark": "深色", + "language": "语言", + "members": "成员", + "profile": "个人资料", + "switchWorkspace": "切换工作区", + "pushNotifications": "推送通知", + "pushNotificationsDescription": "在此设备上接收新消息通知。", + "teams": "团队", + "noMembers": "暂无成员", + "noTeams": "暂无团队", + "roleOwner": "所有者", + "roleAgent": "客服", + "restartRequiredTitle": "需要重启", + "restartRequiredMessage": "切换到此语言会更改应用的布局方向。是否立即重启以应用更改?", + "restartManuallyMessage": "切换到此语言会更改应用的布局方向。请关闭并重新打开应用以应用更改。", + "restartNow": "立即重启", + "restartLater": "稍后", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "套餐" + }, + "errors": { + "unauthorized": "您的会话已过期,请重新登录。", + "workspaceBlocked": "此工作区当前不可用。", + "workspaceBlockedTitle": "工作区不可用", + "workspaceBlockedMac": "此工作区已达到当前套餐的联系人上限,请升级以继续。", + "workspaceBlockedTrialExpired": "此工作区的试用期或套餐已过期,请升级以继续。", + "notFound": "未找到", + "notFoundTitle": "页面未找到", + "notFoundBody": "您访问的页面不存在。", + "goHome": "返回对话", + "unknown": "出错了,请重试。" + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..e4f0835 --- /dev/null +++ b/src/i18n/locales/zh-TW.json @@ -0,0 +1,259 @@ +{ + "common": { + "loading": "載入中…", + "error": "發生錯誤", + "retry": "重試", + "cancel": "取消", + "save": "儲存", + "done": "完成", + "search": "搜尋", + "confirm": "確認", + "delete": "刪除", + "clear": "Clear", + "close": "Close", + "today": "Today", + "yesterday": "Yesterday", + "removeItem": "Remove {{label}}", + "comingSoon": "Coming soon", + "back": "Back" + }, + "auth": { + "signIn": "登入", + "email": "電子郵件", + "password": "密碼", + "forgotPassword": "忘記密碼?", + "signOut": "登出", + "changePassword": "變更密碼", + "changePasswordRequired": "繼續之前需要設定新密碼。", + "currentPassword": "目前密碼", + "newPassword": "新密碼", + "sendResetLink": "傳送重設連結", + "resetLinkSent": "如果該電子郵件存在帳號,重設連結已傳送。", + "orContinueWith": "或使用以下方式繼續", + "continueWithGoogle": "使用 Google 繼續", + "continueWithFacebook": "使用 Facebook 繼續", + "socialSignInFailed": "登入未完成,請再試一次。", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmBody": "You'll need to sign in again to access your workspaces.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "signInSubtitle": "Sign in to keep every conversation moving.", + "forgotPasswordSubtitle": "Enter your email and we'll send you a link to reset your password.", + "backToSignIn": "Back to sign in" + }, + "workspaces": { + "title": "工作區", + "empty": "尚無工作區", + "current": "Current workspace", + "switch": "Switch", + "switchConfirmTitle": "Switch workspace?", + "switchConfirmBody": "Switch to {{name}}? You'll leave your current workspace." + }, + "inbox": { + "title": "Inbox" + }, + "conversations": { + "title": "對話", + "empty": "沒有對話", + "filters": "篩選", + "channel": "頻道", + "handledBy": "處理人", + "status": "狀態", + "clearFilters": "清除篩選", + "filterAll": "全部", + "channelMessenger": "Messenger", + "channelInstagram": "Instagram", + "channelWhatsapp": "WhatsApp", + "channelWebchat": "網頁聊天", + "channelZalo": "Zalo", + "channelTelegram": "Telegram", + "channelEmail": "電子郵件", + "channelTiktok": "TikTok", + "statusUnread": "未讀", + "statusNoAdminReply": "團隊尚未回覆", + "statusFollowUp": "跟進中", + "statusArchived": "已封存", + "statusBlocked": "已封鎖", + "handledByBot": "機器人處理中", + "handledByHuman": "真人處理中", + "unassign": "取消指派", + "noOtherMembers": "此工作區尚無其他成員。", + "unnamed": "未命名", + "unknownContact": "Unknown contact", + "attachmentPreview": "📎 {{count}} attachment(s)", + "a11yUnread": "unread", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "enableBot": "Enable bot", + "disableBot": "Disable bot", + "archive": "Archive", + "archiveUndone": "Conversation restored", + "undo": "Undo", + "botActive": "Bot handling", + "botPaused": "Bot paused", + "assignedTo": "Assigned to {{name}}", + "quickFilterMine": "Mine", + "emptyFiltered": "No conversations match your filters", + "botOn": "On", + "botOff": "Off", + "channelOmnichannel": "Omnichannel", + "botOnOffLabel": "Bot", + "actions": "Conversation actions", + "follow": "Follow", + "unfollow": "Unfollow", + "assign": "Assign", + "unarchive": "Unarchive", + "archiveConfirm": "Archive this conversation? You can find it again later in archived conversations.", + "unarchiveConfirm": "Restore this conversation from the archive?" + }, + "chat": { + "typeAMessage": "輸入訊息", + "send": "傳送", + "failedToSend": "傳送失敗", + "failedToSendTapRetry": "傳送失敗,點一下重試。", + "retrySend": "重試", + "noMessages": "尚無訊息", + "edit": "編輯", + "like": "按讚", + "hide": "隱藏", + "delete": "刪除", + "searchSavedReplies": "搜尋已儲存的回覆", + "noSavedReplies": "沒有已儲存的回覆", + "attachmentTooLarge": "{{fileName}} 超過 5MB。", + "quotaMac": "此工作區已達到目前方案的聯絡人上限,請升級以繼續傳送訊息。", + "quotaTrialExpired": "此工作區的試用期或方案已過期,請升級以繼續傳送訊息。", + "quotaUnknown": "此工作區目前無法使用。", + "dismiss": "關閉", + "grantCameraPermission": "拍照需要相機權限。", + "grantPermission": "授予權限", + "failedToSendPhoto": "照片傳送失敗。", + "typing": "Typing…", + "attach": "Attach", + "camera": "Camera", + "photoLibrary": "Photo library", + "document": "Document", + "sendFlow": "Send flow", + "sendSequence": "Send sequence", + "sendingFlow": "Sending {{name}}…", + "flowSendFailed": "Couldn't send flow. Please try again.", + "savedReplies": "Saved replies", + "attachmentsTooLarge": "{{count}} file(s) are larger than 5MB and weren't sent: {{names}}", + "editingMessage": "Editing message", + "replyingTo": "Replying to {{name}}", + "reply": "Reply", + "editAndResend": "Edit & resend", + "repliedAttachment": "Attachment", + "repliedMessageUnavailable": "Original message unavailable", + "messageDeleted": "Message deleted", + "senderAgent": "You", + "senderBot": "Bot", + "senderContact": "Contact", + "scrollToBottom": "Scroll to latest messages", + "bubbleA11yLabel": "{{sender}} said {{text}}, {{time}}, {{status}}", + "status": { + "pending": "Sending", + "sent": "Sent", + "failed": "Failed to send" + }, + "locationShared": "Location shared", + "unsupportedContent": "[{{contentType}}] Unsupported on mobile", + "activityFallback": "Activity" + }, + "contacts": { + "title": "聯絡人", + "contactTitle": "聯絡人", + "empty": "沒有聯絡人", + "unknown": "未知", + "blocked": "已封鎖", + "email": "電子郵件", + "phone": "電話", + "locale": "語言", + "timezone": "時區", + "location": "地點", + "customFields": "自訂欄位", + "addCustomField": "新增自訂欄位", + "assignedTags": "標籤", + "noTags": "沒有標籤", + "addTag": "新增標籤", + "noSequences": "未加入任何序列", + "sequenceStep": "第 {{step}} 步", + "completed": "已完成", + "active": "進行中", + "tabInfo": "資訊", + "tabTags": "標籤", + "tabSequences": "序列", + "tabNotes": "Notes", + "noNotes": "No notes yet", + "unknownAuthor": "Unknown", + "message": "Message", + "assign": "Assign", + "addToSequence": "Add to sequence", + "noActiveSequences": "No active sequences", + "sequenceSubscribers": "{{count}} subscriber", + "sequenceSubscribers_other": "{{count}} subscribers", + "enrollSelected": "Add ({{count}})", + "sequenceEnrolled": "Contact enrolled in sequence", + "sequenceEnrollFailed": "Couldn't enroll contact in sequence" + }, + "flows": { + "title": "流程", + "empty": "沒有流程", + "readOnlyNote": "流程在行動裝置上為唯讀。", + "active": "已啟用", + "inactive": "未啟用", + "nodes": "{{count}} 個節點", + "nodes_other": "{{count}} 個節點" + }, + "settings": { + "title": "設定", + "darkMode": "深色模式", + "theme": "主題", + "themeSystem": "系統", + "themeLight": "淺色", + "themeDark": "深色", + "language": "語言", + "members": "成員", + "profile": "個人資料", + "switchWorkspace": "切換工作區", + "pushNotifications": "推播通知", + "pushNotificationsDescription": "在此裝置上接收新訊息通知。", + "teams": "團隊", + "noMembers": "沒有成員", + "noTeams": "沒有團隊", + "roleOwner": "擁有者", + "roleAgent": "客服人員", + "restartRequiredTitle": "需要重新啟動", + "restartRequiredMessage": "切換到此語言會變更應用程式的版面方向。是否立即重新啟動以套用?", + "restartManuallyMessage": "切換到此語言會變更應用程式的版面方向。請關閉並重新開啟應用程式以套用。", + "restartNow": "立即重新啟動", + "restartLater": "稍後", + "notifications": "Notifications", + "appearance": "Appearance", + "workspace": "Workspace", + "version": "Version {{version}}" + }, + "profile": { + "plan": "方案" + }, + "errors": { + "unauthorized": "您的工作階段已過期,請重新登入。", + "workspaceBlocked": "此工作區目前無法使用。", + "workspaceBlockedTitle": "工作區無法使用", + "workspaceBlockedMac": "此工作區已達到目前方案的聯絡人上限,請升級以繼續。", + "workspaceBlockedTrialExpired": "此工作區的試用期或方案已過期,請升級以繼續。", + "notFound": "找不到", + "notFoundTitle": "找不到頁面", + "notFoundBody": "您要尋找的頁面不存在。", + "goHome": "回到對話", + "unknown": "發生錯誤,請再試一次。" + }, + "realtime": { + "online": "Back online", + "connecting": "Connecting…", + "reconnecting": "Reconnecting…", + "offline": "You're offline" + }, + "notifications": { + "androidChannelName": "Default" + } +} diff --git a/src/i18n/namespace.ts b/src/i18n/namespace.ts new file mode 100644 index 0000000..2c64c0d --- /dev/null +++ b/src/i18n/namespace.ts @@ -0,0 +1 @@ +export const defaultNamespace = 'translation'; diff --git a/src/i18n/reconcile-rtl.ts b/src/i18n/reconcile-rtl.ts new file mode 100644 index 0000000..0bcbf91 --- /dev/null +++ b/src/i18n/reconcile-rtl.ts @@ -0,0 +1,37 @@ +import { Platform, I18nManager } from 'react-native'; + +import { reloadApp } from '@/i18n/apply-language'; +import { isRTL, type SupportedLanguage } from '@/i18n/locales'; + +/** + * Called once at app bootstrap (splash still up) to make the native `I18nManager` direction match + * the persisted language's writing direction. Unlike `changeAppLanguage`'s user-initiated flow, + * this reloads silently (no dialog) since nothing has rendered yet. + * + * Skipped on web (I18nManager.forceRTL has no effect there — CSS `dir` handles it) and in Expo Go + * `__DEV__`, where `forceRTL` is known not to persist across the JS reload it triggers, which would + * otherwise cause a reload loop; logs a warning there instead. + */ +export async function reconcileRTLOnLaunch(language: SupportedLanguage): Promise<void> { + if (Platform.OS === 'web') { + return; + } + + const targetIsRTL = isRTL(language); + if (targetIsRTL === I18nManager.isRTL) { + return; + } + + if (__DEV__) { + console.warn( + '[i18n] RTL direction mismatch detected in dev — forceRTL may not persist in Expo Go. ' + + 'Use a development build to verify RTL layout.', + ); + return; + } + + I18nManager.allowRTL(true); + I18nManager.forceRTL(targetIsRTL); + + await reloadApp({ silent: true }); +} diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts new file mode 100644 index 0000000..86625d2 --- /dev/null +++ b/src/i18n/resources.ts @@ -0,0 +1,46 @@ +// Metro requires static `require`/`import` paths to bundle JSON — this file exists solely to +// gather every locale's translation JSON into one typed resources record for i18next.init(). +import ar from '@/i18n/locales/ar.json'; +import da from '@/i18n/locales/da.json'; +import de from '@/i18n/locales/de.json'; +import en from '@/i18n/locales/en.json'; +import es from '@/i18n/locales/es.json'; +import fi from '@/i18n/locales/fi.json'; +import fr from '@/i18n/locales/fr.json'; +import he from '@/i18n/locales/he.json'; +import id from '@/i18n/locales/id.json'; +import it from '@/i18n/locales/it.json'; +import ja from '@/i18n/locales/ja.json'; +import nl from '@/i18n/locales/nl.json'; +import ptBR from '@/i18n/locales/pt-BR.json'; +import ptPT from '@/i18n/locales/pt-PT.json'; +import ro from '@/i18n/locales/ro.json'; +import sv from '@/i18n/locales/sv.json'; +import tr from '@/i18n/locales/tr.json'; +import vi from '@/i18n/locales/vi.json'; +import zhCN from '@/i18n/locales/zh-CN.json'; +import zhTW from '@/i18n/locales/zh-TW.json'; +import type { SupportedLanguage } from '@/i18n/locales'; + +export const resources: Record<SupportedLanguage, { translation: typeof en }> = { + ar: { translation: ar }, + da: { translation: da }, + de: { translation: de }, + en: { translation: en }, + es: { translation: es }, + fi: { translation: fi }, + fr: { translation: fr }, + he: { translation: he }, + id: { translation: id }, + it: { translation: it }, + ja: { translation: ja }, + nl: { translation: nl }, + 'pt-BR': { translation: ptBR }, + 'pt-PT': { translation: ptPT }, + ro: { translation: ro }, + sv: { translation: sv }, + tr: { translation: tr }, + vi: { translation: vi }, + 'zh-CN': { translation: zhCN }, + 'zh-TW': { translation: zhTW }, +}; diff --git a/src/lib/describe-api-error.test.ts b/src/lib/describe-api-error.test.ts new file mode 100644 index 0000000..dcc8840 --- /dev/null +++ b/src/lib/describe-api-error.test.ts @@ -0,0 +1,58 @@ +import type { TFunction } from 'i18next'; + +import { ApiError } from '@/api/errors'; +import { describeApiError } from '@/lib/describe-api-error'; + +/** Identity-style mock: returns the key itself so assertions can check which key was resolved, + * matching the pattern the rest of the app's i18n-aware tests use. */ +const t = ((key: string) => key) as TFunction; + +describe('describeApiError', () => { + it('maps unauthorized to errors.unauthorized', () => { + const error = new ApiError({ code: 'UNAUTHORIZED' }); + expect(describeApiError(error, t)).toBe('errors.unauthorized'); + }); + + it('maps mustChangePassword to auth.changePasswordRequired', () => { + const error = new ApiError({ code: 'FORBIDDEN', message: 'Password change required' }); + expect(describeApiError(error, t)).toBe('auth.changePasswordRequired'); + }); + + it('maps notFound to errors.notFound', () => { + const error = new ApiError({ code: 'NOT_FOUND' }); + expect(describeApiError(error, t)).toBe('errors.notFound'); + }); + + it('maps workspaceBlocked/mac to errors.workspaceBlockedMac', () => { + const error = new ApiError({ code: 'workspaceBlocked', message: 'Hit the contact limit' }); + expect(describeApiError(error, t)).toBe('errors.workspaceBlockedMac'); + }); + + it('maps workspaceBlocked/trialExpired to errors.workspaceBlockedTrialExpired', () => { + const error = new ApiError({ status: 402, message: 'Your trial has expired' }); + expect(describeApiError(error, t)).toBe('errors.workspaceBlockedTrialExpired'); + }); + + it('falls back to the server message for workspaceBlocked/unknown', () => { + const error = new ApiError({ status: 402, message: 'Something else' }); + expect(describeApiError(error, t)).toBe('Something else'); + }); + + it("uses normalizeApiError's default message when workspaceBlocked/unknown has no server message", () => { + const error = new ApiError({ status: 402 }); + expect(describeApiError(error, t)).toBe('This workspace is currently unavailable.'); + }); + + it('returns the server message for unknown ApiError with a message', () => { + const error = new ApiError({ code: 'SOMETHING_ELSE', message: 'weird failure' }); + expect(describeApiError(error, t)).toBe('weird failure'); + }); + + it('falls back to errors.unknown for a non-ApiError Error', () => { + expect(describeApiError(new Error(''), t)).toBe('errors.unknown'); + }); + + it("uses normalizeApiError's default message for a non-Error thrown value", () => { + expect(describeApiError('not an error', t)).toBe('Unknown error'); + }); +}); diff --git a/src/lib/describe-api-error.ts b/src/lib/describe-api-error.ts new file mode 100644 index 0000000..c43db0e --- /dev/null +++ b/src/lib/describe-api-error.ts @@ -0,0 +1,43 @@ +import type { TFunction } from 'i18next'; + +import { normalizeApiError } from '@/api/errors'; + +/** + * Centralizes the `NormalizedApiError` → user-facing string mapping duplicated across several + * screens (conversations list, workspace picker, contact panel, contacts directory, members, + * chat screen) — each previously re-implemented its own ternary chain over `normalizeApiError`. + * + * For `workspaceBlocked`, this returns the flat `errors.workspaceBlocked*` copy suitable for a + * single-line `ErrorBanner`. Screens that render a full-screen gate for that kind should keep + * branching on `normalizeApiError(error).kind === 'workspaceBlocked'` themselves (see + * `WorkspaceBlockedGate`, which needs the `reason` to pick MAC-limit vs trial-expired copy, not + * just a string) — `describeApiError` is for the inline-banner case, not a replacement for that + * branch. + */ +export function describeApiError(error: unknown, t: TFunction): string { + const normalized = normalizeApiError(error); + + switch (normalized.kind) { + case 'unauthorized': + return t('errors.unauthorized'); + case 'mustChangePassword': + return t('auth.changePasswordRequired'); + case 'notFound': + return t('errors.notFound'); + case 'workspaceBlocked': + if (normalized.reason === 'mac') return t('errors.workspaceBlockedMac'); + if (normalized.reason === 'trialExpired') return t('errors.workspaceBlockedTrialExpired'); + return normalized.message || t('errors.workspaceBlocked'); + case 'unknown': + return normalized.message || t('errors.unknown'); + default: + return exhaustiveFallback(normalized, t); + } +} + +/** Compile-time exhaustiveness guard: if `NormalizedApiError` ever grows a new `kind`, this fails + * to typecheck rather than silently falling through at runtime. */ +function exhaustiveFallback(value: never, t: TFunction): string { + void value; + return t('errors.unknown'); +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 0000000..c110b04 --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,56 @@ +/** + * Thin wrapper around `expo-haptics`. Every UI primitive that fires a haptic goes through here + * instead of importing `expo-haptics` directly, so the "no-op on web + never throw" guard lives + * in exactly one place. + * + * - Web has no haptics engine; `expo-haptics` methods are unimplemented there, so we short-circuit + * on `Platform.OS === 'web'` rather than relying on the module to no-op gracefully. + * - Haptics can throw *or reject* on devices/simulators without a Taptic Engine (or with OS-level + * haptics disabled, or under Jest where the native module isn't linked at all) — every call is + * both wrapped in try/catch (for synchronous throws) and given a no-op `.catch()` (for async + * rejections, which a bare try/catch around a fire-and-forget promise does NOT catch) so a + * haptic failure never surfaces as a crash or an unhandled promise rejection. + */ +import * as Haptics from 'expo-haptics'; +import { Platform } from 'react-native'; + +export type HapticKind = + 'selection' | 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error'; + +function swallow(): void { + // Haptics are a nice-to-have — never worth surfacing to the caller or crashing over. +} + +/** Fires a haptic feedback pulse for the given semantic kind. Fire-and-forget: callers don't need + * to await this, and it never rejects. */ +export function triggerHaptic(kind: HapticKind): void { + if (Platform.OS === 'web') return; + + try { + switch (kind) { + case 'selection': + Haptics.selectionAsync().catch(swallow); + return; + case 'light': + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(swallow); + return; + case 'medium': + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(swallow); + return; + case 'heavy': + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy).catch(swallow); + return; + case 'success': + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(swallow); + return; + case 'warning': + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(swallow); + return; + case 'error': + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(swallow); + return; + } + } catch { + swallow(); + } +} diff --git a/src/lib/install-crypto-polyfill.test.ts b/src/lib/install-crypto-polyfill.test.ts new file mode 100644 index 0000000..10b8de0 --- /dev/null +++ b/src/lib/install-crypto-polyfill.test.ts @@ -0,0 +1,85 @@ +/** + * `install-crypto-polyfill.ts` runs its polyfill as a module-load side effect, so each case must + * `jest.resetModules()` and re-`require()` it to get a fresh evaluation, and must control + * `globalThis.crypto` itself rather than relying on the ambient jsdom/node environment. + */ +function setGlobalCrypto(value: unknown): void { + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true, + writable: true, + enumerable: true, + }); +} + +/** Re-imports the polyfill fresh (after `jest.resetModules()`) so its side effect re-runs. */ +function loadPolyfill(): void { + // A static top-level import would only ever evaluate once; a dynamic require after + // resetModules() is required to re-run the module's side effect for each case below. + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('./install-crypto-polyfill'); +} + +describe('install-crypto-polyfill', () => { + const originalCrypto = globalThis.crypto; + + afterEach(() => { + jest.resetModules(); + setGlobalCrypto(originalCrypto); + }); + + test('installs a working crypto global when none exists', () => { + // Arrange + setGlobalCrypto(undefined); + jest.resetModules(); + + // Act + loadPolyfill(); + + // Assert + expect(globalThis.crypto).toBeDefined(); + expect(typeof globalThis.crypto.randomUUID).toBe('function'); + expect(typeof globalThis.crypto.getRandomValues).toBe('function'); + }); + + test('randomUUID returns an RFC4122 v4-shaped string', () => { + // Arrange + setGlobalCrypto(undefined); + jest.resetModules(); + loadPolyfill(); + + // Act + const id = globalThis.crypto.randomUUID(); + + // Assert + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + test('getRandomValues fills the provided typed array in place', () => { + // Arrange + setGlobalCrypto(undefined); + jest.resetModules(); + loadPolyfill(); + const buffer = new Uint8Array(16); + + // Act + const result = globalThis.crypto.getRandomValues(buffer); + + // Assert + expect(result).toBe(buffer); + expect(buffer.some((byte) => byte !== 0)).toBe(true); + }); + + test('does not overwrite an existing crypto global', () => { + // Arrange + const existingCrypto = { randomUUID: () => 'existing', getRandomValues: jest.fn() }; + setGlobalCrypto(existingCrypto); + jest.resetModules(); + + // Act + loadPolyfill(); + + // Assert + expect(globalThis.crypto).toBe(existingCrypto); + }); +}); diff --git a/src/lib/install-crypto-polyfill.ts b/src/lib/install-crypto-polyfill.ts new file mode 100644 index 0000000..77352d2 --- /dev/null +++ b/src/lib/install-crypto-polyfill.ts @@ -0,0 +1,53 @@ +import * as ExpoCrypto from 'expo-crypto'; + +/** + * Installs a Web-Crypto-shaped `crypto` global backed by `expo-crypto`. + * + * Why this exists: neither Expo SDK 57's winter runtime (`expo/src/winter`, which installs + * `fetch`/`FormData`/`TextDecoder`/`URL`/`AbortSignal`/`structuredClone`) nor React Native 0.86 + * core defines a global `crypto`. `partysocket` (used by src/realtime/realtime-provider.tsx) + * reads it unconditionally in its `generateUUID()` helper: + * + * if (crypto?.randomUUID) { return crypto.randomUUID(); } + * + * Optional chaining only guards a `null`/`undefined` *value* — it does not guard an undeclared + * *binding*. On Hermes, the bare `crypto` identifier lookup throws + * `ReferenceError: Property 'crypto' doesn't exist` before `?.` can short-circuit, so partysocket's + * own `Math.random()` fallback is never reached. This module makes the global exist so that read + * succeeds, and fixes the same gap for anything else that expects a standard `crypto`. + * + * Imported for its side effect only — see the first import in src/app/_layout.tsx. + * + * This only shapes `randomUUID` and `getRandomValues` — `crypto.subtle` is deliberately not + * implemented. Anything needing real hashing/HMAC (e.g. SHA-256) should call `expo-crypto`'s + * `digest`/`digestStringAsync` directly rather than expecting it on this global. See AGENTS.md. + */ +function installCryptoPolyfill(): void { + if (typeof globalThis.crypto !== 'undefined') { + return; + } + + // expo-crypto's `getRandomValues` is generic over int/uint TypedArrays specifically, narrower + // than DOM's `Crypto.getRandomValues<T extends ArrayBufferView | null>`. Cast once at this + // boundary rather than assigned directly, so callers still get the standard DOM signature; the + // cast is safe because every concrete TypedArray callers pass (partysocket, any future caller) + // satisfies expo-crypto's constraint at runtime. + const getRandomValues = ExpoCrypto.getRandomValues as unknown as Crypto['getRandomValues']; + + const cryptoPolyfill: Pick<Crypto, 'randomUUID' | 'getRandomValues'> = { + randomUUID: () => + ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`, + getRandomValues, + }; + + // Plain assignment can silently no-op if the engine defines `crypto` as a non-writable + // accessor rather than a normal data property; `defineProperty` guarantees the global sticks. + Object.defineProperty(globalThis, 'crypto', { + value: cryptoPolyfill, + configurable: true, + writable: true, + enumerable: true, + }); +} + +installCryptoPolyfill(); diff --git a/src/lib/notification-tap.ts b/src/lib/notification-tap.ts new file mode 100644 index 0000000..c2c7185 --- /dev/null +++ b/src/lib/notification-tap.ts @@ -0,0 +1,63 @@ +import * as Notifications from 'expo-notifications'; +import { router } from 'expo-router'; + +import { useAuthStore } from '@/stores/use-auth-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** + * Data payload the backend attaches to a push notification (the worker sends + * `{ workspaceId, conversationId, messageId }` alongside title/body). All optional because + * this same handler also has to tolerate a malformed/older payload without throwing. + */ +interface NotificationTapData { + workspaceId?: string; + conversationId?: string; + messageId?: string; +} + +function navigateToNotification(data: NotificationTapData): void { + if (!data.conversationId) return; + + if (data.workspaceId && data.workspaceId !== useWorkspaceStore.getState().currentWorkspaceId) { + useWorkspaceStore.getState().setCurrentWorkspaceId(data.workspaceId); + } + + router.push({ + pathname: '/(app)/conversations/[conversationId]', + params: { conversationId: data.conversationId }, + }); +} + +/** Handles a notification tap once the user is authenticated. If auth is still pending (tapped + * from a cold start before sign-in resolves), stashes nothing here — `pending-deep-link.ts` + * already covers the cold-start-URL case; this covers the warm/background-tap case where + * `useAuthStore` is already resolved by the time the tap arrives. */ +function handleResponse(response: Notifications.NotificationResponse): void { + if (useAuthStore.getState().status !== 'signed-in') return; + navigateToNotification(response.notification.request.content.data as NotificationTapData); +} + +/** + * Wires notification-tap → in-app navigation for both cold start (app launched by tapping a + * notification) and warm/background taps. Call once at root layout mount, after auth bootstrap + * has had a chance to resolve for the cold-start case — `getLastNotificationResponseAsync` + * result stays available until consumed, so a short delay here doesn't lose it. + */ +export function initNotificationTapHandling(): () => void { + let cancelled = false; + + Notifications.getLastNotificationResponseAsync() + .then((response) => { + if (!cancelled && response) handleResponse(response); + }) + .catch(() => { + // Best-effort cold-start check — a failure here just means the cold-start tap (if any) is + // missed; the warm/background listener below still covers every subsequent tap. + }); + + const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); + return () => { + cancelled = true; + subscription.remove(); + }; +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..dd7520f --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,97 @@ +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { i18next } from '@/i18n'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; + +/** Shape of the push `data` payload possibly carrying a `conversationId` — mirrors + * `NotificationTapData` in notification-tap.ts (kept as a separate minimal type here since this + * module only reads the one field it needs). */ +interface NotificationConversationData { + conversationId?: string; +} + +// Foreground behavior: show banner + play sound even while the app is open, matching what the +// OS would do if the app were backgrounded — the app has no in-app "toast" for incoming messages. +// EXCEPTION: suppress the banner/list/sound entirely when the notification is about the +// conversation the user is already looking at — `useChatStore.getState()` (not a hook: this +// handler runs outside React, invoked directly by expo-notifications) reads `activeConversationId`, +// set by the chat screen's own `useFocusEffect` (see `(app)/conversations/[conversationId]/index.tsx`). +Notifications.setNotificationHandler({ + handleNotification: async (notification) => { + const data = notification.request.content.data as NotificationConversationData; + const activeConversationId = useChatStore.getState().activeConversationId; + const isForActiveConversation = + Boolean(data?.conversationId) && data.conversationId === activeConversationId; + + if (isForActiveConversation) { + return { + shouldShowBanner: false, + shouldShowList: false, + shouldPlaySound: false, + shouldSetBadge: false, + }; + } + + return { + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }; + }, +}); + +const ANDROID_DEFAULT_CHANNEL_ID = 'default'; + +/** Android requires a notification channel to exist before any notification can display on it — + * call once at startup. No-op on iOS/web. */ +export async function ensureAndroidNotificationChannel(): Promise<void> { + if (Platform.OS !== 'android') return; + + await Notifications.setNotificationChannelAsync(ANDROID_DEFAULT_CHANNEL_ID, { + name: i18next.t('notifications.androidChannelName'), + importance: Notifications.AndroidImportance.DEFAULT, + }); +} + +export type PushTokenResult = + { status: 'granted'; token: string } | { status: 'denied' | 'unsupported' }; + +/** + * Requests notification permission (if not already granted) and returns an Expo push token. + * `unsupported` covers both simulators/emulators (`Device.isDevice` is false — remote push + * doesn't work there) and web. Requires `Constants.expoConfig.extra.eas.projectId`, which only + * exists after `eas init` has run. + */ +export async function getPushTokenAsync(): Promise<PushTokenResult> { + if (!Device.isDevice) { + return { status: 'unsupported' }; + } + + const existing = await Notifications.getPermissionsAsync(); + let finalStatus = existing.status; + + if (finalStatus !== 'granted') { + const requested = await Notifications.requestPermissionsAsync(); + finalStatus = requested.status; + } + + if (finalStatus !== 'granted') { + return { status: 'denied' }; + } + + await ensureAndroidNotificationChannel(); + + const projectId = Constants.expoConfig?.extra?.eas?.projectId as string | undefined; + if (!projectId) { + throw new Error( + 'Missing extra.eas.projectId in app config — run `eas init` before requesting a push token.', + ); + } + + const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId }); + return { status: 'granted', token }; +} diff --git a/src/lib/pending-deep-link.test.ts b/src/lib/pending-deep-link.test.ts new file mode 100644 index 0000000..efde8f5 --- /dev/null +++ b/src/lib/pending-deep-link.test.ts @@ -0,0 +1,80 @@ +// `capturePendingDeepLink` is guarded by a module-scope `captured` flag (only the FIRST call +// across the whole process does anything — see the module's own doc comment) — so each case here +// needs a genuinely fresh module instance to test capture in isolation. `jest.resetModules()` + +// `jest.doMock` + a scoped `require()` (not a top-level `import`) gives each case its own +// instance of both `pending-deep-link.ts` AND the `expo-linking` mock it imports — a plain +// `jest.mock` at the top of the file doesn't survive `resetModules()` for the freshly-required +// module's own import of `expo-linking`. +function freshCapture(url: string | undefined, path: string): string | null { + jest.resetModules(); + jest.doMock('expo-linking', () => ({ + getLinkingURL: jest.fn().mockReturnValue(url), + parse: jest.fn().mockReturnValue({ path }), + })); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate fresh re-require for module-state isolation + const fresh: typeof import('@/lib/pending-deep-link') = require('@/lib/pending-deep-link'); + fresh.capturePendingDeepLink(); + return fresh.consumePendingDeepLink(); +} + +afterEach(() => { + jest.dontMock('expo-linking'); +}); + +describe('capturePendingDeepLink / consumePendingDeepLink', () => { + it('resolves a bare conversations/:id path to the (app) conversation route', () => { + const result = freshCapture('chatbotxmobileapp://conversations/12345', 'conversations/12345'); + expect(result).toBe('/(app)/conversations/12345'); + }); + + it('resolves a path that already embeds the (app) group prefix', () => { + const result = freshCapture( + 'https://example.com/(app)/conversations/12345', + '(app)/conversations/12345', + ); + expect(result).toBe('/(app)/conversations/12345'); + }); + + it('resolves a bare contacts/:id path to the (app) contact route', () => { + const result = freshCapture('chatbotxmobileapp://contacts/999', 'contacts/999'); + expect(result).toBe('/(app)/contacts/999'); + }); + + it('does nothing for an unrecognized path', () => { + const result = freshCapture('chatbotxmobileapp://settings', 'settings'); + expect(result).toBeNull(); + }); + + it('does nothing when there is no launch URL at all', () => { + const result = freshCapture(undefined, ''); + expect(result).toBeNull(); + }); + + it('consuming clears the stashed path so a later call returns null', () => { + jest.resetModules(); + jest.doMock('expo-linking', () => ({ + getLinkingURL: jest.fn().mockReturnValue('chatbotxmobileapp://conversations/1'), + parse: jest.fn().mockReturnValue({ path: 'conversations/1' }), + })); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate fresh re-require for module-state isolation + const fresh: typeof import('@/lib/pending-deep-link') = require('@/lib/pending-deep-link'); + fresh.capturePendingDeepLink(); + + expect(fresh.consumePendingDeepLink()).toBe('/(app)/conversations/1'); + expect(fresh.consumePendingDeepLink()).toBeNull(); + }); + + it('only captures once per module instance — a second call is a no-op', () => { + jest.resetModules(); + jest.doMock('expo-linking', () => ({ + getLinkingURL: jest.fn().mockReturnValue('chatbotxmobileapp://conversations/1'), + parse: jest.fn().mockReturnValue({ path: 'conversations/1' }), + })); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate fresh re-require for module-state isolation + const fresh: typeof import('@/lib/pending-deep-link') = require('@/lib/pending-deep-link'); + fresh.capturePendingDeepLink(); + fresh.capturePendingDeepLink(); // second call: no-op, even though nothing else changed + + expect(fresh.consumePendingDeepLink()).toBe('/(app)/conversations/1'); + }); +}); diff --git a/src/lib/pending-deep-link.ts b/src/lib/pending-deep-link.ts new file mode 100644 index 0000000..b5f9ab2 --- /dev/null +++ b/src/lib/pending-deep-link.ts @@ -0,0 +1,64 @@ +import * as Linking from 'expo-linking'; + +/** + * Cold-start deep link to a conversation or contact (`chatbotxmobileapp://conversations/:id` or + * `.../contacts/:id`, e.g. from a push notification once delivery lands) arrives before the auth + * bootstrap gate resolves. `(app)/_layout.tsx` redirects an unauthenticated visitor to sign-in, + * which drops the original target — expo-router has no built-in "return to this deep link after + * auth" mechanism. This module stashes the resolved route path in memory at launch (read once, + * before any redirect can fire) so sign-in can navigate there instead of the default `/` after a + * successful login. + * + * In-memory only (not persisted): a cold-start deep link is only meaningful for THIS launch: if + * the process is killed before sign-in completes, the notification (once real push delivery + * exists) would redeliver or the user re-opens the link. + */ +let pendingDeepLinkPath: string | null = null; +let captured = false; + +/** Deep-link path shapes this app knows how to resolve to a route, in order of priority. Each + * pattern is matched against the URL's path with the scheme/host stripped (what `Linking.parse` + * gives us) — accepting both a bare `conversations/:id` / `contacts/:id` link (the natural shape + * for a push-notification deep link, e.g. `chatbotxmobileapp://conversations/12345`) and one that + * already embeds the `(app)` route-group prefix (e.g. from a universal link that mirrors the + * app's own internal route paths verbatim). */ +const DEEP_LINK_PATTERNS: { regex: RegExp; buildPath: (id: string) => string }[] = [ + { + regex: /^\/?(?:\(app\)\/)?conversations\/([^/]+)\/?$/, + buildPath: (id) => `/(app)/conversations/${id}`, + }, + { + regex: /^\/?(?:\(app\)\/)?contacts\/([^/]+)\/?$/, + buildPath: (id) => `/(app)/contacts/${id}`, + }, +]; + +/** Call once, as early as possible (root layout module scope) — captures the launch URL before + * any navigation/redirect can consume it. Safe to call multiple times; only the first call has an + * effect, matching the "cold start only" semantics above. */ +export function capturePendingDeepLink(): void { + if (captured) return; + captured = true; + + const url = Linking.getLinkingURL(); + if (!url) return; + + const parsed = Linking.parse(url); + const path = parsed.path ?? ''; + + for (const pattern of DEEP_LINK_PATTERNS) { + const match = pattern.regex.exec(path); + if (match) { + pendingDeepLinkPath = pattern.buildPath(match[1]!); + return; + } + } +} + +/** Consume the stashed deep link (if any) — returns it once, then clears it, so a later normal + * sign-in (e.g. after a manual sign-out) doesn't replay a stale deep link from a previous launch. */ +export function consumePendingDeepLink(): string | null { + const path = pendingDeepLinkPath; + pendingDeepLinkPath = null; + return path; +} diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts new file mode 100644 index 0000000..2c69548 --- /dev/null +++ b/src/lib/query-client.ts @@ -0,0 +1,57 @@ +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; + +import { ApiError, normalizeApiError } from '@/api/errors'; +import { clearAuthToken, getAuthToken } from '@/api/auth-token'; +import { useAuthStore } from '@/stores/use-auth-store'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +/** + * Global error normalization: FORBIDDEN mustChangePassword and 401 route through the auth store so + * the root layout's redirect guard reacts everywhere, not just on the request that triggered it. + * 402 workspaceBlocked is left for the calling screen (composer banner, etc.) to read via + * `normalizeApiError` on its own `error` — this handler only manages the auth-affecting codes. + */ +async function handleGlobalError(error: unknown): Promise<void> { + const normalized = normalizeApiError(error); + + if (normalized.kind === 'mustChangePassword') { + useAuthStore.getState().setMustChangePassword(true); + return; + } + if (normalized.kind === 'unauthorized') { + // A 401 fired before auth bootstrap resolved (or from a request that never carried a token in + // the first place) doesn't mean the stored token is invalid — only force a sign-out when one + // was actually present, otherwise this races the bootstrap and immediately re-signs-out a + // user who was never actually rejected. + const token = await getAuthToken(); + if (!token) return; + + clearAuthToken().catch(() => {}); + useAuthStore.getState().setSignedOut(); + useWorkspaceStore.getState().setCurrentWorkspaceId(null); + } +} + +/** Skip retrying 4xx responses — they're the server telling us the request itself is wrong + * (bad input, unauthorized, not found, blocked), and retrying identically can't change that + * outcome. Only retry once for everything else (network blips, 5xx). */ +function shouldRetry(failureCount: number, error: unknown): boolean { + if (error instanceof ApiError) { + const status = error.body.status; + if (status !== undefined && status >= 400 && status < 500) { + return false; + } + } + return failureCount < 1; +} + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: shouldRetry, + staleTime: 30_000, + }, + }, + queryCache: new QueryCache({ onError: handleGlobalError }), + mutationCache: new MutationCache({ onError: handleGlobalError }), +}); diff --git a/src/realtime/app-state.ts b/src/realtime/app-state.ts new file mode 100644 index 0000000..e013ae1 --- /dev/null +++ b/src/realtime/app-state.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +import { isWorkspaceQuery } from '@/api/query-keys'; + +interface UseRealtimeAppStateOptions { + onBackground: () => void; + onForeground: () => void; +} + +/** + * Wraps AppState so the realtime provider can close its socket on background (saves battery/data, + * avoids a stale connection accumulating missed events indefinitely) and reconnect + refresh on + * return to foreground. `onForeground` is expected to both reconnect the socket AND invalidate the + * active workspace's queries (any events missed while backgrounded are caught by the resulting + * refetch, since there's no event-replay/backfill mechanism on the wire protocol). + */ +export function useRealtimeAppState({ onBackground, onForeground }: UseRealtimeAppStateOptions) { + const previousState = useRef<AppStateStatus>(AppState.currentState); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextState) => { + const wasBackground = previousState.current.match(/inactive|background/); + const isNowActive = nextState === 'active'; + const isNowBackground = nextState.match(/inactive|background/); + + if (isNowBackground && !previousState.current.match(/inactive|background/)) { + onBackground(); + } else if (isNowActive && wasBackground) { + onForeground(); + } + + previousState.current = nextState; + }); + + return () => subscription.remove(); + }, [onBackground, onForeground]); +} + +/** Invalidates every cached query for the given workspace — used as the "refresh on foreground" + * half of the app-state handling, so a reconnect always heals against whatever changed while + * backgrounded. */ +export function invalidateWorkspaceQueries(queryClient: QueryClient, workspaceId: string) { + queryClient.invalidateQueries({ + predicate: (query) => isWorkspaceQuery(query.queryKey, workspaceId), + }); +} diff --git a/src/realtime/apply-conversation-events.test.ts b/src/realtime/apply-conversation-events.test.ts new file mode 100644 index 0000000..fc19180 --- /dev/null +++ b/src/realtime/apply-conversation-events.test.ts @@ -0,0 +1,293 @@ +import { + applyConversationAssigned, + applyConversationMessageCreated, + applyConversationUpdated, + applyContactBlockState, + applyContactDetailBlockState, + type InfiniteConversationsData, +} from './apply-conversation-events'; +import { RealtimeEventType } from './events'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; + +function fakeConversation(overrides: Partial<ConversationListItem> = {}): ConversationListItem { + return { + id: 'conv-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: true, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: null, + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: 'ws-1', + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [], + messages: [], + contact: null, + assignedUser: null, + assignedInboxTeam: null, + ...overrides, + } as unknown as ConversationListItem; +} + +function cacheWith(conversations: ConversationListItem[]): InfiniteConversationsData { + return { + pages: [{ data: conversations, nextCursor: null, prevCursor: null }], + pageParams: [undefined], + } as InfiniteConversationsData; +} + +function cacheWithPages(pages: ConversationListItem[][]): InfiniteConversationsData { + return { + pages: pages.map((data) => ({ data, nextCursor: null, prevCursor: null })), + pageParams: pages.map(() => undefined), + } as InfiniteConversationsData; +} + +function firstPage(result: InfiniteConversationsData): ConversationListItem[] { + return result!.pages[0]!.data; +} + +function pageAt(result: InfiniteConversationsData, index: number): ConversationListItem[] { + return result!.pages[index]!.data; +} + +function createdEvent(data: unknown) { + return { eventType: RealtimeEventType.messageCreated, data } as const; +} + +describe('applyConversationAssigned', () => { + it('patches assignedUserId/assignedInboxTeamId on matching rows only', () => { + const cache = cacheWith([ + fakeConversation({ id: 'conv-1', assignedUserId: null }), + fakeConversation({ id: 'conv-2', assignedUserId: null }), + ]); + const result = applyConversationAssigned(cache, { + eventType: RealtimeEventType.conversationAssigned, + data: { conversationIds: ['conv-1'], assignedUserId: 'user-9', assignedInboxTeamId: null }, + }); + + const data = firstPage(result); + expect(data.find((c) => c.id === 'conv-1')?.assignedUserId).toBe('user-9'); + expect(data.find((c) => c.id === 'conv-2')?.assignedUserId).toBeNull(); + }); +}); + +describe('applyConversationUpdated', () => { + it('applies the changes object to matching rows', () => { + const cache = cacheWith([fakeConversation({ id: 'conv-1', botEnabled: true })]); + const result = applyConversationUpdated(cache, { + eventType: RealtimeEventType.conversationUpdated, + data: { conversationIds: ['conv-1'], changes: { botEnabled: false } }, + }); + + expect(firstPage(result)[0]!.botEnabled).toBe(false); + }); +}); + +describe('applyContactBlockState', () => { + it('patches the embedded contact.blockedAt for matching contactId rows', () => { + const cache = cacheWith([ + fakeConversation({ + id: 'conv-1', + contactId: 'contact-1', + contact: { blockedAt: null } as never, + }), + ]); + const result = applyContactBlockState( + cache, + { eventType: RealtimeEventType.contactBlocked, data: { contactId: 'contact-1' } }, + true, + ); + + expect(firstPage(result)[0]!.contact?.blockedAt).not.toBeNull(); + }); +}); + +describe('applyContactDetailBlockState', () => { + it('sets blockedAt when blocked', () => { + const result = applyContactDetailBlockState({ blockedAt: null }, true); + expect(result?.blockedAt).not.toBeNull(); + }); + + it('clears blockedAt when unblocked', () => { + const result = applyContactDetailBlockState({ blockedAt: '2026-01-01T00:00:00.000Z' }, false); + expect(result?.blockedAt).toBeNull(); + }); + + it('returns undefined unchanged when nothing is cached', () => { + expect(applyContactDetailBlockState(undefined, true)).toBeUndefined(); + }); +}); + +describe('applyConversationMessageCreated', () => { + it('patches lastActivityAt and appends to the messages preview array on the matching row', () => { + const cache = cacheWith([ + fakeConversation({ + id: 'conv-1', + lastActivityAt: '2026-01-01T00:00:00.000Z', + messages: [{ id: 'old-msg', text: 'hi' } as Message], + }), + ]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-1', + createdAt: '2026-01-02T00:00:00.000Z', + text: 'new message', + attachments: [], + }), + ); + + expect(result.found).toBe(true); + const row = firstPage(result.data)[0]!; + expect(row.lastActivityAt).toBe('2026-01-02T00:00:00.000Z'); + expect(row.messages.map((m) => m.id)).toEqual(['old-msg', 'new-msg']); + }); + + it('moves the patched row to the top of its page', () => { + const cache = cacheWith([ + fakeConversation({ id: 'conv-1', messages: [] }), + fakeConversation({ id: 'conv-2', messages: [] }), + fakeConversation({ id: 'conv-3', messages: [] }), + ]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-3', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + ); + + expect(firstPage(result.data).map((c) => c.id)).toEqual(['conv-3', 'conv-1', 'conv-2']); + }); + + it('reports found=false and leaves the cache unchanged when the conversation is not cached', () => { + const cache = cacheWith([fakeConversation({ id: 'conv-1' })]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'not-cached', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + ); + + expect(result.found).toBe(false); + expect(firstPage(result.data).map((c) => c.id)).toEqual(['conv-1']); + }); + + it('reports found=false when there is nothing cached at all', () => { + const result = applyConversationMessageCreated(undefined, createdEvent({ id: 'm1' })); + expect(result.found).toBe(false); + expect(result.data).toBeUndefined(); + }); + + it('reports found=false for a malformed/un-narrowable payload', () => { + const cache = cacheWith([fakeConversation({ id: 'conv-1' })]); + const result = applyConversationMessageCreated(cache, createdEvent(null)); + expect(result.found).toBe(false); + expect(result.data).toBe(cache); + }); + + it('caps the preview messages array at the max length', () => { + const existing = Array.from({ length: 20 }, (_, i) => ({ id: `msg-${i}` }) as Message); + const cache = cacheWith([fakeConversation({ id: 'conv-1', messages: existing })]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-1', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + ); + + const row = firstPage(result.data)[0]!; + expect(row.messages).toHaveLength(20); + expect(row.messages[row.messages.length - 1]!.id).toBe('new-msg'); + expect(row.messages[0]!.id).toBe('msg-1'); + }); + + it('patches a matching row on a LATER page in place instead of hoisting it to page 0', () => { + // Regression: hoisting across a page boundary duplicated the row in the client's merged view + // and desynced every subsequent page's cursor — a row found on page 1+ must stay on its own + // page, patched in place. + const cache = cacheWithPages([ + [fakeConversation({ id: 'conv-1' }), fakeConversation({ id: 'conv-2' })], + [fakeConversation({ id: 'conv-3' }), fakeConversation({ id: 'conv-4' })], + ]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-4', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + ); + + expect(result.found).toBe(true); + // Page 0 is untouched — the row was not hoisted onto it. + expect(pageAt(result.data, 0).map((c) => c.id)).toEqual(['conv-1', 'conv-2']); + // Page 1 keeps its own row order — only patched in place, not moved to the front. + expect(pageAt(result.data, 1).map((c) => c.id)).toEqual(['conv-3', 'conv-4']); + expect(pageAt(result.data, 1)[1]!.lastActivityAt).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('bumps agentLastReadAt when the message belongs to the currently-active conversation', () => { + const cache = cacheWith([ + fakeConversation({ id: 'conv-1', agentLastReadAt: '2025-01-01T00:00:00.000Z' }), + ]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-1', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + 'conv-1', + ); + + expect(firstPage(result.data)[0]!.agentLastReadAt).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('leaves agentLastReadAt untouched when the message is for a different conversation than the active one', () => { + const cache = cacheWith([ + fakeConversation({ id: 'conv-1', agentLastReadAt: '2025-01-01T00:00:00.000Z' }), + ]); + + const result = applyConversationMessageCreated( + cache, + createdEvent({ + id: 'new-msg', + conversationId: 'conv-1', + createdAt: '2026-01-02T00:00:00.000Z', + attachments: [], + }), + 'conv-2', + ); + + expect(firstPage(result.data)[0]!.agentLastReadAt).toBe('2025-01-01T00:00:00.000Z'); + }); +}); diff --git a/src/realtime/apply-conversation-events.ts b/src/realtime/apply-conversation-events.ts new file mode 100644 index 0000000..8171a34 --- /dev/null +++ b/src/realtime/apply-conversation-events.ts @@ -0,0 +1,177 @@ +import type { + RealtimeEventConversationAssigned, + RealtimeEventConversationUpdated, + RealtimeEventContactCommon, + RealtimeEventCreateMessage, +} from '@/realtime/events'; +import { narrowIncomingMessage } from '@/realtime/apply-message-events'; +import type { + ConversationListItem, + ListConversationsResponse, +} from '@/features/conversations/api/use-conversations-infinite'; + +/** Pure functions patching the conversations-list infinite-query cache shape — see + * apply-message-events.ts for the same design rationale (pure, immutable, testable later). */ +export type InfiniteConversationsData = + { pages: ListConversationsResponse[]; pageParams: unknown[] } | undefined; + +function patchMatching( + current: InfiniteConversationsData, + matches: (item: ConversationListItem) => boolean, + patch: Partial<ConversationListItem>, +): InfiniteConversationsData { + if (!current) return current; + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((item) => (matches(item) ? { ...item, ...patch } : item)), + })), + }; +} + +export function applyConversationAssigned( + current: InfiniteConversationsData, + event: RealtimeEventConversationAssigned, +): InfiniteConversationsData { + const idSet = new Set(event.data.conversationIds); + return patchMatching(current, (item) => idSet.has(item.id), { + assignedUserId: event.data.assignedUserId, + assignedInboxTeamId: event.data.assignedInboxTeamId, + }); +} + +export function applyConversationUpdated( + current: InfiniteConversationsData, + event: RealtimeEventConversationUpdated, +): InfiniteConversationsData { + const idSet = new Set(event.data.conversationIds); + return patchMatching(current, (item) => idSet.has(item.id), event.data.changes); +} + +/** `contactBlocked`/`contactUnblocked` patch the embedded `contact.blockedAt` field on every + * conversation row for that contact — a contact can back multiple conversations (see the + * assign-by-contact applier for the same one-contact-many-conversations pattern). */ +export function applyContactBlockState( + current: InfiniteConversationsData, + event: RealtimeEventContactCommon, + blocked: boolean, +): InfiniteConversationsData { + if (!current) return current; + const { contactId } = event.data; + + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((item) => + item.contactId === contactId && item.contact + ? { + ...item, + contact: { ...item.contact, blockedAt: blocked ? new Date().toISOString() : null }, + } + : item, + ), + })), + }; +} + +/** Patches a standalone `queryKeys.ws.contacts.detail` cache entry's `blockedAt` field — added + * alongside the contacts feature. `applyContactBlockState` above only reaches the + * conversations-list cache's embedded contact object; this covers the separate contact-detail + * query the contact panel reads (use-contact-detail.ts), which has its own `blockedAt` field at + * the top level (not nested under `contact`). Callers scan for a query keyed + * `['ws', workspaceId, 'contacts', 'detail', contactId]` and setQueryData through this. */ +export function applyContactDetailBlockState<T extends { blockedAt: string | null }>( + current: T | undefined, + blocked: boolean, +): T | undefined { + if (!current) return current; + return { ...current, blockedAt: blocked ? new Date().toISOString() : null }; +} + +export interface ApplyConversationMessageCreatedResult { + data: InfiniteConversationsData; + /** Whether the incoming message's conversation was found (and patched) in ANY cached list page. + * `false` means no row exists in cache to patch — use-realtime-handlers.ts uses this signal to + * fall back to a debounced `invalidateQueries` instead of fabricating a new row from the + * partial/untyped message payload. */ + found: boolean; +} + +/** + * Companion to `applyMessageCreated` in apply-message-events.ts: when a `messageCreated` event + * arrives, the messages-list cache for the open conversation gets the new message (that applier), + * and this one ALSO patches the matching conversation-list ROW so the inbox preview stays live + * without a refetch — updates `lastActivityAt`, splices the message onto the row's `messages[]` + * preview array (capped to the last few entries, matching `conversation-row.tsx`'s + * `lastMessagePreview` which only ever reads the LAST entry). When the matched row is on the + * FIRST page, it's also moved to the front of that page (conventional "most recent conversation + * first" ordering) — a row found on a later page is patched in place instead, since the list is + * cursor-paginated and splicing a row from page N to the front of page 0 would duplicate it + * across pages and desync every subsequent page's cursor. + * + * `activeConversationId` — when it matches the incoming message's conversation — also bumps + * `agentLastReadAt` on the patched row, so the inbox unread badge doesn't drift stale while the + * user is actively looking at that conversation's chat screen (the realtime event is the only + * signal here; the user isn't calling the read-receipt mutation just by having the screen open). + * + * Deliberately does NOT attempt to fabricate a brand-new row when the conversation isn't cached + * anywhere — the event payload is a single message, not a full conversation row, so there's no + * contact/channel/assignee data to build one from. Callers should invalidate instead (see `found` + * on the return value). + */ +const MAX_PREVIEW_MESSAGES = 20; + +export function applyConversationMessageCreated( + current: InfiniteConversationsData, + event: RealtimeEventCreateMessage, + activeConversationId: string | null = null, +): ApplyConversationMessageCreatedResult { + if (!current) return { data: current, found: false }; + + const incoming = narrowIncomingMessage(event.data); + if (!incoming) return { data: current, found: false }; + + let found = false; + + const pages = current.pages.map((page, pageIndex) => { + const matchIndex = page.data.findIndex((item) => item.id === incoming.conversationId); + if (matchIndex < 0) return page; + + found = true; + const matched = page.data[matchIndex]!; + const patchedRow: ConversationListItem = { + ...matched, + lastActivityAt: incoming.createdAt, + messages: [...matched.messages, incoming].slice(-MAX_PREVIEW_MESSAGES), + ...(incoming.conversationId === activeConversationId + ? { agentLastReadAt: incoming.createdAt } + : {}), + }; + + if (pageIndex !== 0) { + // Not the first page — patch in place. Hoisting here would move the row across a page + // boundary, duplicating it in the client's merged view and corrupting cursor pagination. + return { + ...page, + data: page.data.map((item, index) => (index === matchIndex ? patchedRow : item)), + }; + } + + // First page only: move the patched row to the front — mirrors "most recently active + // conversation first" ordering without waiting on a refetch. + const rest = page.data.filter((_, index) => index !== matchIndex); + return { ...page, data: [patchedRow, ...rest] }; + }); + + return { data: { ...current, pages }, found }; +} + +// `conversationCreated` carries the full new conversation row as `data: unknown` (the source +// package deliberately keeps this untyped to avoid a database-schema dependency — see events.ts +// comment). Splicing an unknown-shaped row into the strongly-typed `ConversationListItem[]` cache +// risks silently corrupting it with missing fields the UI assumes exist, so there's no applier +// function for it here — use-realtime-handlers.ts calls `queryClient.invalidateQueries` directly +// for the active conversations-list keys instead: one extra network round-trip on a relatively +// rare event, in exchange for never rendering a malformed row. diff --git a/src/realtime/apply-message-events.test.ts b/src/realtime/apply-message-events.test.ts new file mode 100644 index 0000000..f2e160d --- /dev/null +++ b/src/realtime/apply-message-events.test.ts @@ -0,0 +1,120 @@ +import { applyMessageCreated, type InfiniteMessagesData } from '@/realtime/apply-message-events'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import { RealtimeEventType } from '@/realtime/events'; + +function cacheWith(messages: Partial<Message>[]): InfiniteMessagesData { + return { + pages: [{ data: messages as Message[], nextCursor: null, prevCursor: null }], + pageParams: [undefined], + } as InfiniteMessagesData; +} + +function createdEvent(data: unknown) { + return { eventType: RealtimeEventType.messageCreated, data } as const; +} + +function firstPage(result: InfiniteMessagesData): Message[] { + return result!.pages[0]!.data; +} + +const CONVERSATION_A = 'conv-a'; +const CONVERSATION_B = 'conv-b'; + +describe('applyMessageCreated', () => { + it('defaults a missing attachments field to an empty array', () => { + // A wire payload without `attachments` would otherwise land in the cache and crash + // message-bubble.tsx on `message.attachments.length` — the same bug as the optimistic entry, + // reached from the websocket side instead. + const result = applyMessageCreated( + cacheWith([]), + createdEvent({ id: 'srv-1', text: 'hi', conversationId: CONVERSATION_A }), + CONVERSATION_A, + ); + + expect(firstPage(result)[0].attachments).toEqual([]); + }); + + it('passes existing attachments through untouched', () => { + const attachments = [{ id: 'att-1', fileType: 'image', url: 'https://cdn/x.jpg' }]; + const result = applyMessageCreated( + cacheWith([]), + createdEvent({ id: 'srv-1', text: 'hi', attachments, conversationId: CONVERSATION_A }), + CONVERSATION_A, + ); + + expect(firstPage(result)[0].attachments).toEqual(attachments); + }); + + it('ignores a payload with no usable id', () => { + const cache = cacheWith([{ id: 'existing' }]); + + expect(applyMessageCreated(cache, createdEvent({ text: 'no id' }), CONVERSATION_A)).toBe(cache); + expect(applyMessageCreated(cache, createdEvent(null), CONVERSATION_A)).toBe(cache); + }); + + it('replaces a pending optimistic entry that shares the clientId', () => { + const cache = cacheWith([{ id: 'optimistic-99', clientId: '99', text: 'pending' }]); + + const result = applyMessageCreated( + cache, + createdEvent({ + id: 'srv-1', + clientId: '99', + text: 'confirmed', + attachments: [], + conversationId: CONVERSATION_A, + }), + CONVERSATION_A, + ); + + const data = firstPage(result); + expect(data).toHaveLength(1); + expect(data[0].id).toBe('srv-1'); + expect(data[0].text).toBe('confirmed'); + }); + + it('prepends a message that is not already cached', () => { + const result = applyMessageCreated( + cacheWith([{ id: 'older' }]), + createdEvent({ id: 'srv-1', attachments: [], conversationId: CONVERSATION_A }), + CONVERSATION_A, + ); + + expect(firstPage(result).map((message) => message.id)).toEqual(['srv-1', 'older']); + }); + + it('does not duplicate a message already present by id', () => { + const result = applyMessageCreated( + cacheWith([{ id: 'srv-1' }]), + createdEvent({ id: 'srv-1', attachments: [], conversationId: CONVERSATION_A }), + CONVERSATION_A, + ); + + expect(firstPage(result)).toHaveLength(1); + }); + + it('no-ops when the incoming message belongs to a different conversation (cross-conversation leak guard)', () => { + const cache = cacheWith([{ id: 'existing-b' }]); + + const result = applyMessageCreated( + cache, + createdEvent({ id: 'srv-from-a', attachments: [], conversationId: CONVERSATION_A }), + CONVERSATION_B, + ); + + expect(result).toBe(cache); + expect(firstPage(result).map((message) => message.id)).toEqual(['existing-b']); + }); + + it('applies when the incoming message conversationId matches the target', () => { + const cache = cacheWith([{ id: 'existing-a' }]); + + const result = applyMessageCreated( + cache, + createdEvent({ id: 'srv-from-a', attachments: [], conversationId: CONVERSATION_A }), + CONVERSATION_A, + ); + + expect(firstPage(result).map((message) => message.id)).toEqual(['srv-from-a', 'existing-a']); + }); +}); diff --git a/src/realtime/apply-message-events.ts b/src/realtime/apply-message-events.ts new file mode 100644 index 0000000..5c39dd9 --- /dev/null +++ b/src/realtime/apply-message-events.ts @@ -0,0 +1,168 @@ +import type { + RealtimeEventCreateMessage, + RealtimeEventMessageDeleted, + RealtimeEventMessageFailed, + RealtimeEventMessageIdAssigned, + RealtimeEventMessageUpdated, +} from '@/realtime/events'; +import type { ListMessagesResponse, Message } from '@/features/chat/api/use-messages-infinite'; + +/** + * Pure functions: `(currentData, event) => newData`, never mutating the input — called from + * use-realtime-handlers.ts via `queryClient.setQueryData`. Kept pure/side-effect-free so they stay + * easy to unit test later (the plan notes jest-expo coverage for exactly this kind of pure cache + * applier, even though no test infra exists yet this phase). + */ +export type InfiniteMessagesData = + { pages: ListMessagesResponse[]; pageParams: unknown[] } | undefined; + +/** Normalizes as well as narrows: `attachments` is required by the `Message` type and dereferenced + * unguarded by message-bubble.tsx, but nothing guarantees a wire payload includes it. Defaulting it + * here (rather than only guarding at render) keeps the cache well-formed for every consumer. + * Exported so `apply-conversation-events.ts` and other realtime helpers can reuse the same + * narrowing when they only need the conversationId/id out of an untyped `messageCreated` payload. */ +export function narrowIncomingMessage(data: unknown): Message | null { + if (!data || typeof data !== 'object') return null; + if (!('id' in data) || typeof data.id !== 'string') return null; + const message = data as Message; + return Array.isArray(message.attachments) ? message : { ...message, attachments: [] }; +} + +/** + * messageCreated: if the event carries a `clientId` that matches a pending optimistic entry in + * cache (our own send, echoed back), replace that entry with the confirmed message — this is the + * SAME reconciliation use-send-message.ts already does from its own mutation response, so this + * path mainly matters for messages that arrive from OTHER clients/channels. If no match, prepend + * as a new message (guarding against a duplicate by id, in case the same event is delivered twice + * or the sender's own mutation-response reconciliation already inserted it). + * + * `targetConversationId` guards against the cross-conversation cache leak: the caller + * (use-realtime-handlers.ts) is expected to only route a `messageCreated` event to the ONE + * matching conversation's messages-list query key in the first place, but this guard is kept as + * defense-in-depth (e.g. against a future caller that fans this out more broadly again) — a + * message whose own `conversationId` doesn't match the query it's being applied to is a no-op. + */ +export function applyMessageCreated( + current: InfiniteMessagesData, + event: RealtimeEventCreateMessage, + targetConversationId: string, +): InfiniteMessagesData { + if (!current) return current; + const incoming = narrowIncomingMessage(event.data); + if (!incoming) return current; + if (incoming.conversationId !== targetConversationId) return current; + + const [firstPage, ...restPages] = current.pages; + if (!firstPage) return current; + + const clientId = 'clientId' in incoming ? incoming.clientId : undefined; + const pendingIndex = clientId + ? firstPage.data.findIndex((message) => 'clientId' in message && message.clientId === clientId) + : -1; + const alreadyPresent = firstPage.data.some((message) => message.id === incoming.id); + + let newData: Message[]; + if (pendingIndex >= 0) { + newData = firstPage.data.map((message, index) => (index === pendingIndex ? incoming : message)); + } else if (alreadyPresent) { + newData = firstPage.data; + } else { + newData = [incoming, ...firstPage.data]; + } + + return { ...current, pages: [{ ...firstPage, data: newData }, ...restPages] }; +} + +export function applyMessageDeleted( + current: InfiniteMessagesData, + event: RealtimeEventMessageDeleted, +): InfiniteMessagesData { + if (!current) return current; + const deletedIds = new Set(event.data.messageIds); + + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((message) => + deletedIds.has(message.id) ? { ...message, deletedAt: new Date().toISOString() } : message, + ), + })), + }; +} + +/** + * Patches the edited text only. The web app's equivalent handler (`updateMessageText`) also + * reconstructs a full attachment object from `newAttachmentPath`/`newAttachmentMimeType`/etc when an attachment was + * added/replaced/removed during the edit — deliberately NOT mirrored here, since this app's own + * edit-message UI is itself a stub (no attachment-edit affordance exists yet to trigger this + * server-side), so building the full attachment-reconstruction logic now would have nothing to + * exercise it. Revisit once message edit UI grows an attachment-replace flow. + */ +export function applyMessageUpdated( + current: InfiniteMessagesData, + event: RealtimeEventMessageUpdated, +): InfiniteMessagesData { + if (!current) return current; + const { messageId, newText } = event.data; + + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((message) => + message.id === messageId ? { ...message, text: newText } : message, + ), + })), + }; +} + +/** Mirrors the `__optimisticStatus`/`__optimisticError` shape use-send-message.ts's onError + * already writes, so message-bubble.tsx's existing rendering (via getOptimisticStatus) picks this + * up unchanged — this is the realtime-driven version of the same failure signal, for cases where + * the failure is detected server-side/async rather than by the sender's own mutation rejecting. */ +export function applyMessageFailed( + current: InfiniteMessagesData, + event: RealtimeEventMessageFailed, +): InfiniteMessagesData { + if (!current) return current; + const { messageId, clientId, error } = event.data; + + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((message) => { + const matches = clientId + ? 'clientId' in message && message.clientId === clientId + : message.id === messageId; + if (!matches) return message; + return { + ...message, + __optimisticStatus: 'failed', + __optimisticError: error ?? 'Failed to send', + } as unknown as Message; + }), + })), + }; +} + +/** Mirrors the web app's `assignMessageCommentId` handler exactly — the comment id lands on + * `sourceId`, not `parentId` (parentId means something different: thread/reply linkage). */ +export function applyMessageIdAssigned( + current: InfiniteMessagesData, + event: RealtimeEventMessageIdAssigned, +): InfiniteMessagesData { + if (!current) return current; + const { messageId, commentId } = event.data; + + return { + ...current, + pages: current.pages.map((page) => ({ + ...page, + data: page.data.map((message) => + message.id === messageId ? { ...message, sourceId: commentId } : message, + ), + })), + }; +} diff --git a/src/realtime/events.ts b/src/realtime/events.ts new file mode 100644 index 0000000..394c073 --- /dev/null +++ b/src/realtime/events.ts @@ -0,0 +1,136 @@ +/** + * MIRRORED TYPES — mirrors the backend's realtime event schema (no shared package; this repo is + * standalone, so there is no live cross-repo import). + * + * When the backend's event schema changes, re-copy `RealtimeEventType` / `RealtimeEvent*` here. + * Last synced 2026-08-21 (added conversationCreated/conversationUpdated). + */ + +export const RealtimeEventType = { + messageCreated: 'messageCreated', + messageDeleted: 'messageDeleted', + messageUpdated: 'messageUpdated', + messageIdAssigned: 'messageIdAssigned', + messageFailed: 'messageFailed', + typing: 'typing', + contactBlocked: 'contactBlocked', + contactUnblocked: 'contactUnblocked', + conversationAssigned: 'conversationAssigned', + notifyExportResult: 'notifyExportResult', + conversationCreated: 'conversationCreated', + conversationUpdated: 'conversationUpdated', +} as const; + +export type RealtimeEventCreateMessage = { + eventType: typeof RealtimeEventType.messageCreated; + data: unknown; +}; + +export type RealtimeEventMessageDeleted = { + eventType: typeof RealtimeEventType.messageDeleted; + data: { + messageIds: string[]; + }; +}; + +export type RealtimeEventMessageIdAssigned = { + eventType: typeof RealtimeEventType.messageIdAssigned; + data: { + messageId: string; + commentId: string; + }; +}; + +export type RealtimeEventMessageUpdated = { + eventType: typeof RealtimeEventType.messageUpdated; + data: { + messageId: string; + newText: string; + newAttachmentPath?: string | null; + newAttachmentPublicUrl?: string | null; + newAttachmentMimeType?: string | null; + newAttachmentWidth?: number; + newAttachmentHeight?: number; + removedAttachment?: boolean; + }; +}; + +export type RealtimeEventMessageFailed = { + eventType: typeof RealtimeEventType.messageFailed; + data: { + messageId: string; + clientId?: string; + error?: string | null; + }; +}; + +export type RealtimeEventTyping = { + eventType: typeof RealtimeEventType.typing; + data: { + conversationId: string; + typing: boolean; + seconds: number; + }; +}; + +export type RealtimeEventContactCommon = { + eventType: typeof RealtimeEventType.contactBlocked | typeof RealtimeEventType.contactUnblocked; + data: { + contactId: string; + }; +}; + +export type RealtimeEventConversationAssigned = { + eventType: typeof RealtimeEventType.conversationAssigned; + data: { + conversationIds: string[]; + assignedUserId: string | null; + assignedInboxTeamId: string | null; + }; +}; + +export type RealtimeEventNotifyExportResult = { + eventType: typeof RealtimeEventType.notifyExportResult; + data: { + outputPath: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + error?: string; + }; +}; + +export type RealtimeEventConversationCreated = { + eventType: typeof RealtimeEventType.conversationCreated; + // Full conversation row — shape owned by @chatbotx.io/business's ConversationModel; kept as + // `unknown` here to match the source package (avoids a dependency on the database schema). + data: unknown; +}; + +export type RealtimeEventConversationUpdatedChanges = { + archivedAt?: string | null; + assignedUserId?: string | null; + assignedInboxTeamId?: string | null; + followed?: boolean; + agentLastReadAt?: string | null; + botEnabled?: boolean; +}; + +export type RealtimeEventConversationUpdated = { + eventType: typeof RealtimeEventType.conversationUpdated; + data: { + conversationIds: string[]; + changes: RealtimeEventConversationUpdatedChanges; + }; +}; + +export type RealtimeEventData = + | RealtimeEventCreateMessage + | RealtimeEventMessageDeleted + | RealtimeEventMessageIdAssigned + | RealtimeEventMessageUpdated + | RealtimeEventMessageFailed + | RealtimeEventContactCommon + | RealtimeEventConversationAssigned + | RealtimeEventTyping + | RealtimeEventNotifyExportResult + | RealtimeEventConversationCreated + | RealtimeEventConversationUpdated; diff --git a/src/realtime/one-time-token.ts b/src/realtime/one-time-token.ts new file mode 100644 index 0000000..c8f1c16 --- /dev/null +++ b/src/realtime/one-time-token.ts @@ -0,0 +1,30 @@ +import { env } from '@/config/env'; + +/** + * `GET /api/auth/one-time-token/generate` — better-auth's `oneTimeToken()` plugin (enabled on the + * backend). Hand-rolled fetch, same pattern as api/auth-endpoints.ts, since this isn't part of + * the oRPC-generated apiClient. + */ +export class OneTimeTokenError extends Error { + constructor(message: string) { + super(message); + this.name = 'OneTimeTokenError'; + } +} + +export async function generateOneTimeToken(bearerToken: string): Promise<string> { + const response = await fetch(`${env.apiBaseUrl}/api/auth/one-time-token/generate`, { + headers: { Authorization: `Bearer ${bearerToken}` }, + }); + + if (!response.ok) { + throw new OneTimeTokenError(`Failed to generate one-time token (${response.status})`); + } + + const body = (await response.json()) as { token?: string } | null; + if (!body?.token) { + throw new OneTimeTokenError('One-time token response missing "token" field.'); + } + + return body.token; +} diff --git a/src/realtime/parse-event.test.ts b/src/realtime/parse-event.test.ts new file mode 100644 index 0000000..2050697 --- /dev/null +++ b/src/realtime/parse-event.test.ts @@ -0,0 +1,277 @@ +import { parseRealtimeEvent } from './parse-event'; +import { RealtimeEventType } from './events'; + +describe('parseRealtimeEvent', () => { + describe('malformed input', () => { + it('returns null for garbage (non-JSON) text', () => { + expect(parseRealtimeEvent('not json{{')).toBeNull(); + }); + + it('returns null for a JSON value that is not an object', () => { + expect(parseRealtimeEvent('42')).toBeNull(); + expect(parseRealtimeEvent('"a string"')).toBeNull(); + expect(parseRealtimeEvent('[1,2,3]')).toBeNull(); + expect(parseRealtimeEvent('null')).toBeNull(); + }); + + it('returns null when eventType is missing', () => { + expect(parseRealtimeEvent(JSON.stringify({ data: {} }))).toBeNull(); + }); + + it('returns null when eventType is not a recognized value', () => { + expect(parseRealtimeEvent(JSON.stringify({ eventType: 'bogusEvent', data: {} }))).toBeNull(); + }); + + it('returns null when eventType is not a string', () => { + expect(parseRealtimeEvent(JSON.stringify({ eventType: 123, data: {} }))).toBeNull(); + }); + }); + + describe('messageCreated', () => { + it('accepts any present data payload (untyped by design)', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageCreated, + data: { id: 'm1', text: 'hi' }, + }); + const result = parseRealtimeEvent(raw); + expect(result?.eventType).toBe(RealtimeEventType.messageCreated); + }); + }); + + describe('messageDeleted', () => { + it('accepts a valid messageIds array', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageDeleted, + data: { messageIds: ['a', 'b'] }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a non-array messageIds', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageDeleted, + data: { messageIds: 'a' }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + + it('rejects a messageIds array with non-string elements', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageDeleted, + data: { messageIds: [1, 2] }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('messageIdAssigned', () => { + it('accepts a valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageIdAssigned, + data: { messageId: 'm1', commentId: 'c1' }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a missing commentId', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageIdAssigned, + data: { messageId: 'm1' }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('messageUpdated', () => { + it('accepts a minimal valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageUpdated, + data: { messageId: 'm1', newText: 'edited' }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('accepts a full payload with optional attachment fields', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageUpdated, + data: { + messageId: 'm1', + newText: 'edited', + newAttachmentPath: 'path.jpg', + newAttachmentPublicUrl: 'https://x/y.jpg', + newAttachmentMimeType: 'image/jpeg', + newAttachmentWidth: 100, + newAttachmentHeight: 200, + removedAttachment: false, + }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a wrong-typed newText', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageUpdated, + data: { messageId: 'm1', newText: 123 }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + + it('rejects a wrong-typed newAttachmentWidth', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageUpdated, + data: { messageId: 'm1', newText: 'x', newAttachmentWidth: 'wide' }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('messageFailed', () => { + it('accepts a valid payload with a null error', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageFailed, + data: { messageId: 'm1', error: null }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('accepts an optional clientId', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageFailed, + data: { messageId: 'm1', clientId: 'c1', error: 'boom' }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a missing messageId', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.messageFailed, + data: { error: null }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('typing', () => { + it('accepts a valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'c1', typing: true, seconds: 5 }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a missing seconds field', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'c1', typing: true }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + + it('rejects a non-boolean typing field', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'c1', typing: 'yes', seconds: 5 }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('contactBlocked / contactUnblocked', () => { + it('accepts a valid payload for both types', () => { + expect( + parseRealtimeEvent( + JSON.stringify({ + eventType: RealtimeEventType.contactBlocked, + data: { contactId: 'c1' }, + }), + ), + ).not.toBeNull(); + expect( + parseRealtimeEvent( + JSON.stringify({ + eventType: RealtimeEventType.contactUnblocked, + data: { contactId: 'c1' }, + }), + ), + ).not.toBeNull(); + }); + + it('rejects a missing contactId', () => { + const raw = JSON.stringify({ eventType: RealtimeEventType.contactBlocked, data: {} }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('conversationAssigned', () => { + it('accepts a valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationAssigned, + data: { conversationIds: ['c1'], assignedUserId: 'u1', assignedInboxTeamId: null }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a non-array conversationIds', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationAssigned, + data: { conversationIds: 'c1', assignedUserId: null, assignedInboxTeamId: null }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('notifyExportResult', () => { + it('accepts a valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.notifyExportResult, + data: { outputPath: '/out.csv', status: 'completed' }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects an invalid status value', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.notifyExportResult, + data: { outputPath: '/out.csv', status: 'bogus' }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); + + describe('conversationCreated', () => { + it('accepts any present data payload (untyped by design)', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationCreated, + data: { id: 'conv1' }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + }); + + describe('conversationUpdated', () => { + it('accepts a valid payload', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationUpdated, + data: { conversationIds: ['c1'], changes: { botEnabled: true, followed: false } }, + }); + expect(parseRealtimeEvent(raw)).not.toBeNull(); + }); + + it('rejects a missing changes object', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationUpdated, + data: { conversationIds: ['c1'] }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + + it('rejects a wrong-typed field inside changes', () => { + const raw = JSON.stringify({ + eventType: RealtimeEventType.conversationUpdated, + data: { conversationIds: ['c1'], changes: { botEnabled: 'yes' } }, + }); + expect(parseRealtimeEvent(raw)).toBeNull(); + }); + }); +}); diff --git a/src/realtime/parse-event.ts b/src/realtime/parse-event.ts new file mode 100644 index 0000000..fdd65b6 --- /dev/null +++ b/src/realtime/parse-event.ts @@ -0,0 +1,169 @@ +import { RealtimeEventType, type RealtimeEventData } from './events'; + +/** + * Hand-rolled runtime type guards for every `RealtimeEventData` variant in events.ts — no zod + * dependency in this project (see AGENTS.md/plan), so each event shape is validated by hand. + * `parseRealtimeEvent` is the single entry point: parses the raw websocket text, validates the + * envelope (`eventType` + `data` object), then dispatches to the per-type guard. Returns `null` + * on ANY malformation (invalid JSON, non-object, unrecognized eventType, wrong-shaped data) + * rather than throwing — realtime-provider.tsx's onMessage handler is expected to `__DEV__ warn` + * and drop the event rather than crash the socket handler. + */ + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +/** True when `value` is absent, `null`, or a string — the common shape for the optional + * nullable-string fields scattered across these event payloads. */ +function isOptionalNullableString(value: unknown): boolean { + return value === undefined || value === null || typeof value === 'string'; +} + +function validateMessageCreated(data: unknown): data is { data: unknown } { + // The source package deliberately keeps this payload untyped (see events.ts comment) — any + // object-or-not value is "valid" at this layer; downstream `narrowIncomingMessage` in + // apply-message-events.ts does the real per-field narrowing before it touches the cache. + return data !== undefined; +} + +function validateMessageDeleted(data: unknown): boolean { + return isObject(data) && isStringArray(data.messageIds); +} + +function validateMessageIdAssigned(data: unknown): boolean { + return isObject(data) && isString(data.messageId) && isString(data.commentId); +} + +function validateMessageUpdated(data: unknown): boolean { + if (!isObject(data)) return false; + if (!isString(data.messageId) || !isString(data.newText)) return false; + if (!isOptionalNullableString(data.newAttachmentPath)) return false; + if (!isOptionalNullableString(data.newAttachmentPublicUrl)) return false; + if (!isOptionalNullableString(data.newAttachmentMimeType)) return false; + if (data.newAttachmentWidth !== undefined && typeof data.newAttachmentWidth !== 'number') + return false; + if (data.newAttachmentHeight !== undefined && typeof data.newAttachmentHeight !== 'number') + return false; + if (data.removedAttachment !== undefined && typeof data.removedAttachment !== 'boolean') + return false; + return true; +} + +function validateMessageFailed(data: unknown): boolean { + if (!isObject(data)) return false; + if (!isString(data.messageId)) return false; + if (data.clientId !== undefined && typeof data.clientId !== 'string') return false; + // `error` is optional — a payload that omits it entirely (rather than sending `null`) must not + // fail validation and drop the whole event. + if (!isOptionalNullableString(data.error)) return false; + return true; +} + +function validateTyping(data: unknown): boolean { + return ( + isObject(data) && + isString(data.conversationId) && + typeof data.typing === 'boolean' && + typeof data.seconds === 'number' + ); +} + +function validateContactCommon(data: unknown): boolean { + return isObject(data) && isString(data.contactId); +} + +function validateConversationAssigned(data: unknown): boolean { + if (!isObject(data)) return false; + if (!isStringArray(data.conversationIds)) return false; + if (typeof data.assignedUserId !== 'string' && data.assignedUserId !== null) return false; + if (typeof data.assignedInboxTeamId !== 'string' && data.assignedInboxTeamId !== null) + return false; + return true; +} + +const NOTIFY_EXPORT_STATUSES = new Set(['pending', 'processing', 'completed', 'failed']); + +function validateNotifyExportResult(data: unknown): boolean { + if (!isObject(data)) return false; + if (!isString(data.outputPath)) return false; + if (!isString(data.status) || !NOTIFY_EXPORT_STATUSES.has(data.status)) return false; + if (data.error !== undefined && typeof data.error !== 'string') return false; + return true; +} + +function validateConversationCreated(data: unknown): boolean { + // Untyped payload by design (see events.ts) — any presence is "valid" here. + return data !== undefined; +} + +function validateConversationUpdatedChanges(changes: unknown): boolean { + if (!isObject(changes)) return false; + if (!isOptionalNullableString(changes.archivedAt)) return false; + if (!isOptionalNullableString(changes.assignedUserId)) return false; + if (!isOptionalNullableString(changes.assignedInboxTeamId)) return false; + if (changes.followed !== undefined && typeof changes.followed !== 'boolean') return false; + if (!isOptionalNullableString(changes.agentLastReadAt)) return false; + if (changes.botEnabled !== undefined && typeof changes.botEnabled !== 'boolean') return false; + return true; +} + +function validateConversationUpdated(data: unknown): boolean { + return ( + isObject(data) && + isStringArray(data.conversationIds) && + validateConversationUpdatedChanges(data.changes) + ); +} + +/** Parses and validates a raw websocket text payload into a `RealtimeEventData`, or `null` if the + * payload is malformed JSON, not an object, missing/unrecognized `eventType`, or has a `data` + * shape that doesn't match the event type. Never throws. */ +export function parseRealtimeEvent(raw: string): RealtimeEventData | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!isObject(parsed)) return null; + const { eventType, data } = parsed; + if (typeof eventType !== 'string') return null; + + switch (eventType) { + case RealtimeEventType.messageCreated: + return validateMessageCreated(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.messageDeleted: + return validateMessageDeleted(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.messageIdAssigned: + return validateMessageIdAssigned(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.messageUpdated: + return validateMessageUpdated(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.messageFailed: + return validateMessageFailed(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.typing: + return validateTyping(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.contactBlocked: + case RealtimeEventType.contactUnblocked: + return validateContactCommon(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.conversationAssigned: + return validateConversationAssigned(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.notifyExportResult: + return validateNotifyExportResult(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.conversationCreated: + return validateConversationCreated(data) ? (parsed as RealtimeEventData) : null; + case RealtimeEventType.conversationUpdated: + return validateConversationUpdated(data) ? (parsed as RealtimeEventData) : null; + default: + return null; + } +} diff --git a/src/realtime/realtime-provider.tsx b/src/realtime/realtime-provider.tsx new file mode 100644 index 0000000..bb32027 --- /dev/null +++ b/src/realtime/realtime-provider.tsx @@ -0,0 +1,135 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { usePartySocket } from 'partysocket/react'; +import type { PropsWithChildren } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +import { getAuthToken } from '@/api/auth-token'; +import { env } from '@/config/env'; +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +import { invalidateWorkspaceQueries, useRealtimeAppState } from './app-state'; +import { generateOneTimeToken } from './one-time-token'; +import { parseRealtimeEvent } from './parse-event'; +import { clearAllTypingTimers } from './typing-timers'; +import { resetConnectionStore, useConnectionStore } from './use-connection-store'; +import { clearPendingConversationInvalidates, useRealtimeHandlers } from './use-realtime-handlers'; + +/** + * Connects to the `workspaces` PartyKit party, room = workspaceId (mirrors the web app's + * realtime connection). + * + * `?domain=` is REQUIRED on every mobile connection: the realtime server verifies the one-time + * token by resolving a tenant origin from either the WebSocket request's `Origin` header (browsers + * send this automatically) or a `?domain=<https url>` query param — React Native's WebSocket + * implementation sends no `Origin` header at all, so without `domain` the server has nothing to + * verify the token against and rejects the connection. The value must be the + * SAME origin that serves `/api/auth/one-time-token/verify` — i.e. `env.apiBaseUrl` — not the + * realtime server's own host. + */ +function ConnectedRealtimeProvider({ + children, + workspaceId, +}: PropsWithChildren<{ workspaceId: string }>) { + const queryClient = useQueryClient(); + const handleEvent = useRealtimeHandlers(queryClient, workspaceId); + const setStatus = useConnectionStore((state) => state.setStatus); + const markOpened = useConnectionStore((state) => state.markOpened); + + // Resets the connection store synchronously during this component's FIRST render — not in a + // post-mount effect. `usePartySocket` below starts connecting immediately on mount, so an + // effect-based reset can run AFTER `onOpen` has already fired and flipped the store to 'open', + // clobbering it back to 'connecting' and leaving the banner stuck. `useState`'s lazy initializer + // is the React-compiler-safe way to run this exactly once per mounted instance — reading/writing + // a ref's `.current` during render (the more obvious "one-shot" pattern) is disallowed under + // `reactCompiler: true` (see app.config.ts), since the compiler may re-execute render bodies. + // The parent's `key={workspaceId}` already guarantees a fresh instance per workspace. + useState(() => { + resetConnectionStore(); + return null; + }); + + const socket = usePartySocket({ + host: env.wsUrl, + party: 'workspaces', + room: workspaceId, + query: async () => { + const authToken = await getAuthToken(); + if (!authToken) { + return {}; + } + const oneTimeToken = await generateOneTimeToken(authToken); + return { token: oneTimeToken, domain: env.apiBaseUrl }; + }, + onMessage(event) { + const parsed = parseRealtimeEvent(event.data); + if (!parsed) { + if (__DEV__) { + console.warn('[realtime] dropped malformed event payload', event.data); + } + return; + } + handleEvent(parsed); + }, + onOpen() { + // `hasEverOpened` is read BEFORE `markOpened` flips it, so this correctly distinguishes the + // very first connect (no reconciliation needed — the initial query fetches are already + // fresh) from a reconnect after a drop (where events may have been missed while + // disconnected, so the active workspace's queries need to be reconciled against the server). + if (useConnectionStore.getState().hasEverOpened) { + invalidateWorkspaceQueries(queryClient, workspaceId); + } + markOpened(); + }, + onClose() { + setStatus('closed'); + }, + onError() { + setStatus('closed'); + }, + }); + + // Runs once per mount — workspaceId is this component's whole identity via the parent's + // `key={workspaceId}`, so a dependency array is unnecessary; teardown clears both the typing + // timers and the debounced conversations-list invalidation timers this instance's realtime + // handlers may have scheduled, so neither leaks past the connection they belong to. + useEffect(() => { + return () => { + clearAllTypingTimers(); + clearPendingConversationInvalidates(); + }; + }, []); + + const handleBackground = useCallback(() => { + socket.close(); + }, [socket]); + + const handleForeground = useCallback(() => { + socket.reconnect(); + invalidateWorkspaceQueries(queryClient, workspaceId); + }, [socket, queryClient, workspaceId]); + + useRealtimeAppState({ onBackground: handleBackground, onForeground: handleForeground }); + + return children; +} + +/** Only connects once a workspace is selected — defensive, since the tabs layout already guards + * on `currentWorkspaceId` before mounting this provider, but a null id here should never attempt + * a connection rather than crash on an empty room name. */ +export function RealtimeProvider({ children }: PropsWithChildren) { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + if (!workspaceId) { + return children; + } + + return ( + // `key={workspaceId}` forces a full remount on workspace switch, tearing down the old + // PartySocket connection (and its usePartySocket internal state) rather than reusing the + // component instance with a new `room` prop — cleaner than relying on the socket library to + // detect and rejoin a different room on its own. + <ConnectedRealtimeProvider key={workspaceId} workspaceId={workspaceId}> + {children} + </ConnectedRealtimeProvider> + ); +} diff --git a/src/realtime/typing-timers.test.ts b/src/realtime/typing-timers.test.ts new file mode 100644 index 0000000..f6c51e1 --- /dev/null +++ b/src/realtime/typing-timers.test.ts @@ -0,0 +1,90 @@ +import { clearAllTypingTimers, clearTypingTimer, scheduleTypingExpiry } from './typing-timers'; + +describe('typing-timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + clearAllTypingTimers(); + }); + + afterEach(() => { + clearAllTypingTimers(); + jest.useRealTimers(); + }); + + it('calls onExpire after the given number of seconds', () => { + const onExpire = jest.fn(); + scheduleTypingExpiry('conv-1', 5, onExpire); + + jest.advanceTimersByTime(4999); + expect(onExpire).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('resets the countdown when scheduled again for the same conversation', () => { + const onExpire = jest.fn(); + scheduleTypingExpiry('conv-1', 5, onExpire); + + jest.advanceTimersByTime(3000); + scheduleTypingExpiry('conv-1', 5, onExpire); + + jest.advanceTimersByTime(4000); + expect(onExpire).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('clearTypingTimer prevents onExpire from firing', () => { + const onExpire = jest.fn(); + scheduleTypingExpiry('conv-1', 5, onExpire); + + clearTypingTimer('conv-1'); + jest.advanceTimersByTime(10_000); + + expect(onExpire).not.toHaveBeenCalled(); + }); + + it('clearTypingTimer is a no-op for an unknown conversationId', () => { + expect(() => clearTypingTimer('does-not-exist')).not.toThrow(); + }); + + it('tracks timers independently per conversation', () => { + const onExpireA = jest.fn(); + const onExpireB = jest.fn(); + scheduleTypingExpiry('conv-a', 2, onExpireA); + scheduleTypingExpiry('conv-b', 5, onExpireB); + + jest.advanceTimersByTime(2000); + expect(onExpireA).toHaveBeenCalledTimes(1); + expect(onExpireB).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + expect(onExpireB).toHaveBeenCalledTimes(1); + }); + + it('clearAllTypingTimers cancels every pending timer', () => { + const onExpireA = jest.fn(); + const onExpireB = jest.fn(); + scheduleTypingExpiry('conv-a', 2, onExpireA); + scheduleTypingExpiry('conv-b', 5, onExpireB); + + clearAllTypingTimers(); + jest.advanceTimersByTime(10_000); + + expect(onExpireA).not.toHaveBeenCalled(); + expect(onExpireB).not.toHaveBeenCalled(); + }); + + it('defaults seconds=0 to firing immediately when advanced by 0ms (TTL math handled by caller)', () => { + // Note: the "default to 5s when seconds is 0/undefined" fallback lives in the CALLER + // (use-realtime-handlers.ts), not this scheduler — scheduleTypingExpiry always honors + // whatever `seconds` it's given, including 0. + const onExpire = jest.fn(); + scheduleTypingExpiry('conv-1', 0, onExpire); + + jest.advanceTimersByTime(0); + expect(onExpire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/realtime/typing-timers.ts b/src/realtime/typing-timers.ts new file mode 100644 index 0000000..8522d0c --- /dev/null +++ b/src/realtime/typing-timers.ts @@ -0,0 +1,46 @@ +/** + * Expiry timers for realtime `typing` events. The `typing` payload carries a `seconds` TTL (see + * events.ts's `RealtimeEventTyping`) that was previously ignored entirely — once + * `setTyping(conversationId, true)` fired, nothing ever cleared it if the sender's own + * `typing: false` follow-up was dropped/delayed, so "Typing…" could stick forever. This module is + * a tiny scheduler keyed by conversationId, backed by a module-level timer map (not React state — + * it's driven from use-realtime-handlers.ts, outside any component's render). + */ + +const timers = new Map<string, ReturnType<typeof setTimeout>>(); + +/** Schedules `onExpire` to run after `seconds` (falling back to a default TTL for 0/undefined — + * see use-realtime-handlers.ts's call site), clearing any existing timer for the same + * conversationId first so a fresh `typing: true` event resets the countdown rather than stacking + * timers. */ +export function scheduleTypingExpiry( + conversationId: string, + seconds: number, + onExpire: () => void, +): void { + clearTypingTimer(conversationId); + const timer = setTimeout(() => { + timers.delete(conversationId); + onExpire(); + }, seconds * 1000); + timers.set(conversationId, timer); +} + +/** Clears the pending expiry timer for one conversation, if any — called when an explicit + * `typing: false` event arrives (the sender stopped before the TTL elapsed) so it doesn't fire a + * redundant `setTyping(id, false)` later. */ +export function clearTypingTimer(conversationId: string): void { + const existing = timers.get(conversationId); + if (existing) { + clearTimeout(existing); + timers.delete(conversationId); + } +} + +/** Clears every pending expiry timer — called on socket teardown (workspace switch/unmount) so no + * stale timer from a torn-down connection fires after a new one has taken over the same + * conversationId keys. */ +export function clearAllTypingTimers(): void { + timers.forEach((timer) => clearTimeout(timer)); + timers.clear(); +} diff --git a/src/realtime/use-connection-store.ts b/src/realtime/use-connection-store.ts new file mode 100644 index 0000000..342a628 --- /dev/null +++ b/src/realtime/use-connection-store.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; + +/** + * Client-state store mirroring the PartySocket connection's lifecycle (see realtime-provider.tsx, + * which wires `usePartySocket`'s `onOpen`/`onClose`/`onError` into this). Purely reflects socket + * state — it does not own reconnect logic (partysocket handles retry itself). Consumed by + * `ConnectionBanner` call sites (Inbox + chat screen) to render a real connecting/reconnecting/ + * offline indicator instead of the previous hardcoded 'online'. + */ +export type ConnectionStatus = 'connecting' | 'open' | 'closed'; + +interface ConnectionStoreState { + status: ConnectionStatus; + /** Whether the socket has ever successfully reached `open` at least once during this app + * session. Distinguishes the FIRST connect (status 'connecting' with `hasEverOpened: false` → + * banner shows "Connecting…") from a later drop-and-retry (status 'connecting'/'closed' with + * `hasEverOpened: true` → banner shows "Reconnecting…"/"You're offline"). Reset to `false` on + * every workspace switch since realtime-provider.tsx remounts (`key={workspaceId}`) and this + * store is a plain singleton, not scoped per-provider-instance — see `resetConnectionStore`. */ + hasEverOpened: boolean; + setStatus: (status: ConnectionStatus) => void; + markOpened: () => void; +} + +export const useConnectionStore = create<ConnectionStoreState>((set) => ({ + status: 'connecting', + hasEverOpened: false, + setStatus: (status) => set({ status }), + markOpened: () => set({ status: 'open', hasEverOpened: true }), +})); + +/** Resets the store back to its initial "never connected" state — call when the realtime provider + * itself is torn down for a reason that should NOT be read as "reconnecting" (e.g. workspace + * switch, where the next connect is a fresh first-connect from the new provider's point of view, + * not a resume of the old one). */ +export function resetConnectionStore(): void { + useConnectionStore.setState({ status: 'connecting', hasEverOpened: false }); +} + +/** Maps the store's `status`/`hasEverOpened` pair to `ConnectionBanner`'s four-state prop, so both + * mount sites (Inbox header, chat screen) render identical banner text/color for the same + * underlying socket state instead of each hand-rolling the mapping. `open` intentionally still + * renders as `ConnectionBanner`'s `'online'` state briefly (see that component's own auto-hide + * behavior) rather than being hidden here — the banner owns "when to disappear". */ +export function useConnectionBannerState(): 'online' | 'connecting' | 'reconnecting' | 'offline' { + return useConnectionStore((state) => { + if (state.status === 'open') return 'online'; + if (!state.hasEverOpened) return 'connecting'; + return state.status === 'connecting' ? 'reconnecting' : 'offline'; + }); +} diff --git a/src/realtime/use-realtime-handlers.test.ts b/src/realtime/use-realtime-handlers.test.ts new file mode 100644 index 0000000..caeae85 --- /dev/null +++ b/src/realtime/use-realtime-handlers.test.ts @@ -0,0 +1,224 @@ +import { QueryClient } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-native'; + +import { queryKeys } from '@/api/query-keys'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import type { Message } from '@/features/chat/api/use-messages-infinite'; +import type { ConversationListItem } from '@/features/conversations/api/use-conversations-infinite'; +import { RealtimeEventType } from './events'; +import { useRealtimeHandlers } from './use-realtime-handlers'; + +const WORKSPACE_ID = 'ws-1'; + +function messagesCache(messages: Partial<Message>[]) { + return { + pages: [{ data: messages as Message[], nextCursor: null, prevCursor: null }], + pageParams: [undefined], + }; +} + +function fakeConversation(overrides: Partial<ConversationListItem> = {}): ConversationListItem { + return { + id: 'conv-a', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + botEnabled: true, + botResumeAt: null, + archivedAt: null, + additionalAttributes: null, + contactLastReadAt: null, + agentLastReadAt: null, + lastActivityAt: '2026-01-01T00:00:00.000Z', + followed: false, + workspaceId: WORKSPACE_ID, + contactId: 'contact-1', + sourceId: null, + lastStep: null, + currentStep: null, + adminRepliedAt: null, + contactRepliedAt: null, + contactInboxes: [], + messages: [], + contact: null, + assignedUser: null, + assignedInboxTeam: null, + ...overrides, + } as unknown as ConversationListItem; +} + +function conversationsCache(conversations: ConversationListItem[]) { + return { + pages: [{ data: conversations, nextCursor: null, prevCursor: null }], + pageParams: [undefined], + }; +} + +describe('useRealtimeHandlers', () => { + beforeEach(() => { + jest.useFakeTimers(); + useChatStore.setState({ typingByConversation: {} }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('routes messageCreated to only the matching conversation messages cache, leaving others untouched', async () => { + const queryClient = new QueryClient(); + const keyA = queryKeys.ws.messages.list(WORKSPACE_ID, 'conv-a'); + const keyB = queryKeys.ws.messages.list(WORKSPACE_ID, 'conv-b'); + queryClient.setQueryData(keyA, messagesCache([{ id: 'existing-a' }])); + queryClient.setQueryData(keyB, messagesCache([{ id: 'existing-b' }])); + + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.messageCreated, + data: { id: 'new-msg', conversationId: 'conv-a', attachments: [] }, + }); + + const dataA = queryClient.getQueryData<ReturnType<typeof messagesCache>>(keyA); + const dataB = queryClient.getQueryData<ReturnType<typeof messagesCache>>(keyB); + + expect(dataA!.pages[0]!.data.map((m) => m.id)).toEqual(['new-msg', 'existing-a']); + expect(dataB!.pages[0]!.data.map((m) => m.id)).toEqual(['existing-b']); + }); + + it('patches the conversations-list row for the matching conversation on messageCreated', async () => { + const queryClient = new QueryClient(); + const listKey = queryKeys.ws.conversations.list(WORKSPACE_ID, {}); + queryClient.setQueryData( + listKey, + conversationsCache([fakeConversation({ id: 'conv-a', messages: [] })]), + ); + + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.messageCreated, + data: { + id: 'new-msg', + conversationId: 'conv-a', + createdAt: '2026-01-05T00:00:00.000Z', + attachments: [], + }, + }); + + const data = queryClient.getQueryData<ReturnType<typeof conversationsCache>>(listKey); + const row = data!.pages[0]!.data[0]!; + expect(row.lastActivityAt).toBe('2026-01-05T00:00:00.000Z'); + expect(row.messages.map((m) => m.id)).toEqual(['new-msg']); + }); + + it('debounces an invalidation of the conversations list when the conversation is not cached', async () => { + const queryClient = new QueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const listKey = queryKeys.ws.conversations.list(WORKSPACE_ID, {}); + queryClient.setQueryData( + listKey, + conversationsCache([fakeConversation({ id: 'some-other-conv' })]), + ); + + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.messageCreated, + data: { id: 'new-msg', conversationId: 'not-cached', attachments: [] }, + }); + + expect(invalidateSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(500); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['ws', WORKSPACE_ID, 'conversations', 'list'] }), + ); + }); + + it('clears the typing flag automatically after the event TTL elapses', async () => { + const queryClient = new QueryClient(); + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'conv-a', typing: true, seconds: 1 }, + }); + + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(true); + + jest.advanceTimersByTime(1000); + + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(false); + }); + + it('does not clear typing early when an explicit typing:false event has not arrived', async () => { + const queryClient = new QueryClient(); + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'conv-a', typing: true, seconds: 5 }, + }); + + jest.advanceTimersByTime(4000); + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(true); + }); + + it('clears the timer when an explicit typing:false event arrives before the TTL', async () => { + const queryClient = new QueryClient(); + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'conv-a', typing: true, seconds: 5 }, + }); + result.current({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'conv-a', typing: false, seconds: 0 }, + }); + + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(false); + + // The set-to-false spy check: advancing well past the original TTL should not throw or + // re-trigger anything odd (timer was cleared, nothing scheduled for false events). + jest.advanceTimersByTime(10_000); + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(false); + }); + + it('defaults to a 5s TTL when seconds is 0', async () => { + const queryClient = new QueryClient(); + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.typing, + data: { conversationId: 'conv-a', typing: true, seconds: 0 }, + }); + + jest.advanceTimersByTime(4999); + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(true); + jest.advanceTimersByTime(1); + expect(useChatStore.getState().typingByConversation['conv-a']).toBe(false); + }); + + it('conversationUpdated patches only the matching conversations-list row', async () => { + const queryClient = new QueryClient(); + const listKey = queryKeys.ws.conversations.list(WORKSPACE_ID, {}); + queryClient.setQueryData( + listKey, + conversationsCache([ + fakeConversation({ id: 'conv-a', botEnabled: true }), + fakeConversation({ id: 'conv-b', botEnabled: true }), + ]), + ); + + const { result } = await renderHook(() => useRealtimeHandlers(queryClient, WORKSPACE_ID)); + + result.current({ + eventType: RealtimeEventType.conversationUpdated, + data: { conversationIds: ['conv-a'], changes: { botEnabled: false } }, + }); + + const data = queryClient.getQueryData<ReturnType<typeof conversationsCache>>(listKey); + const rows = data!.pages[0]!.data; + expect(rows.find((r) => r.id === 'conv-a')?.botEnabled).toBe(false); + expect(rows.find((r) => r.id === 'conv-b')?.botEnabled).toBe(true); + }); +}); diff --git a/src/realtime/use-realtime-handlers.ts b/src/realtime/use-realtime-handlers.ts new file mode 100644 index 0000000..2af0f51 --- /dev/null +++ b/src/realtime/use-realtime-handlers.ts @@ -0,0 +1,235 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { queryKeys } from '@/api/query-keys'; +import { useChatStore } from '@/features/chat/stores/use-chat-store'; +import { narrowIncomingMessage, type InfiniteMessagesData } from './apply-message-events'; +import { + applyMessageCreated, + applyMessageDeleted, + applyMessageFailed, + applyMessageIdAssigned, + applyMessageUpdated, +} from './apply-message-events'; +import type { InfiniteConversationsData } from './apply-conversation-events'; +import { + applyConversationMessageCreated, + applyContactBlockState, + applyContactDetailBlockState, + applyConversationAssigned, + applyConversationUpdated, +} from './apply-conversation-events'; +import { scheduleTypingExpiry, clearTypingTimer } from './typing-timers'; +import { RealtimeEventType, type RealtimeEventData } from './events'; + +/** Default typing-indicator TTL (seconds) when the event's own `seconds` field is 0/undefined — + * per the plan, a payload that doesn't specify a TTL still shouldn't stick forever. */ +const DEFAULT_TYPING_TTL_SECONDS = 5; + +/** How long to wait, after a `messageCreated` event whose conversation isn't found in ANY cached + * list page, before invalidating the conversations list — debounced (rather than firing per + * event) so a burst of messages for the same not-yet-cached conversation doesn't trigger a + * refetch storm. Keyed by workspaceId since this hook is itself workspace-scoped. */ +const CONVERSATION_INVALIDATE_DEBOUNCE_MS = 500; +const pendingConversationInvalidate = new Map<string, ReturnType<typeof setTimeout>>(); + +function debounceInvalidateConversationsList(queryClient: QueryClient, workspaceId: string) { + const existing = pendingConversationInvalidate.get(workspaceId); + if (existing) clearTimeout(existing); + + const timer = setTimeout(() => { + pendingConversationInvalidate.delete(workspaceId); + queryClient.invalidateQueries({ queryKey: ['ws', workspaceId, 'conversations', 'list'] }); + }, CONVERSATION_INVALIDATE_DEBOUNCE_MS); + pendingConversationInvalidate.set(workspaceId, timer); +} + +/** Clears every pending debounced conversations-list invalidation across all workspaces — + * these are module-scope timers (not tied to any component's lifecycle), so without an explicit + * teardown they outlive the realtime connection that scheduled them, leaking a timer per + * not-yet-cached conversation and holding the Jest process open after tests finish. Call this + * alongside `clearAllTypingTimers()` wherever the realtime connection itself is torn down. */ +export function clearPendingConversationInvalidates(): void { + pendingConversationInvalidate.forEach((timer) => clearTimeout(timer)); + pendingConversationInvalidate.clear(); +} + +/** + * Dispatches a parsed realtime event to the right cache applier. + * + * `messageCreated` is special-cased: it's routed ONLY to the one messages-list query key matching + * its own `conversationId` (rather than fanning out to every cached messages query like the other + * message events below) — this is the actual fix for the cross-conversation leak where a message + * from conversation A could get prepended into conversation B's cache if both happened to be + * cached simultaneously. `applyMessageCreated`'s own `targetConversationId` guard is + * defense-in-depth on top of this. It also patches the conversations-list ROW for the same + * conversation via `applyConversationMessageCreated` so the inbox preview updates live. + * + * The other message events (`messageDeleted`/`messageUpdated`/`messageFailed`/ + * `messageIdAssigned`) match by message id already, so fanning out to every cached messages query + * and letting each applier no-op on a non-matching cache is safe and is left unchanged. + * + * Conversation events patch every cached conversations-list query (any filter combination) for + * this workspace, same as the conversation-action mutations' cache patches. + */ +export function useRealtimeHandlers(queryClient: QueryClient, workspaceId: string) { + return useCallback( + (event: RealtimeEventData) => { + switch (event.eventType) { + case RealtimeEventType.messageCreated: { + const incoming = narrowIncomingMessage(event.data); + if (!incoming) break; + + queryClient.setQueryData<InfiniteMessagesData>( + queryKeys.ws.messages.list(workspaceId, incoming.conversationId), + (old) => applyMessageCreated(old, event, incoming.conversationId), + ); + + let foundInAnyPage = false; + const activeConversationId = useChatStore.getState().activeConversationId; + queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .forEach((query) => { + queryClient.setQueryData<InfiniteConversationsData>(query.queryKey, (old) => { + const result = applyConversationMessageCreated(old, event, activeConversationId); + if (result.found) foundInAnyPage = true; + return result.data; + }); + }); + + if (!foundInAnyPage) { + debounceInvalidateConversationsList(queryClient, workspaceId); + } + break; + } + + case RealtimeEventType.messageDeleted: + case RealtimeEventType.messageUpdated: + case RealtimeEventType.messageFailed: + case RealtimeEventType.messageIdAssigned: { + queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'messages', + }) + .forEach((query) => { + queryClient.setQueryData<InfiniteMessagesData>(query.queryKey, (old) => { + switch (event.eventType) { + case RealtimeEventType.messageDeleted: + return applyMessageDeleted(old, event); + case RealtimeEventType.messageUpdated: + return applyMessageUpdated(old, event); + case RealtimeEventType.messageFailed: + return applyMessageFailed(old, event); + case RealtimeEventType.messageIdAssigned: + return applyMessageIdAssigned(old, event); + default: + return old; + } + }); + }); + break; + } + + case RealtimeEventType.typing: { + const { conversationId, typing, seconds } = event.data; + // Read via `getState()` rather than a hook-selected value: this callback is memoized by + // `useCallback` below (deps: queryClient/workspaceId only) and also invoked from the + // typing-timer's own expiry closure, so it needs the CURRENT store action each time it + // runs rather than one captured at a particular render — `getState()` always returns the + // live store, zustand action references are stable, so this is safe outside React. + useChatStore.getState().setTyping(conversationId, typing, seconds); + + if (typing) { + scheduleTypingExpiry(conversationId, seconds || DEFAULT_TYPING_TTL_SECONDS, () => + useChatStore.getState().setTyping(conversationId, false), + ); + } else { + clearTypingTimer(conversationId); + } + break; + } + + case RealtimeEventType.conversationAssigned: + case RealtimeEventType.conversationUpdated: + case RealtimeEventType.contactBlocked: + case RealtimeEventType.contactUnblocked: { + queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'conversations' && + query.queryKey[3] === 'list', + }) + .forEach((query) => { + queryClient.setQueryData<InfiniteConversationsData>(query.queryKey, (old) => { + switch (event.eventType) { + case RealtimeEventType.conversationAssigned: + return applyConversationAssigned(old, event); + case RealtimeEventType.conversationUpdated: + return applyConversationUpdated(old, event); + case RealtimeEventType.contactBlocked: + return applyContactBlockState(old, event, true); + case RealtimeEventType.contactUnblocked: + return applyContactBlockState(old, event, false); + default: + return old; + } + }); + }); + + if ( + event.eventType === RealtimeEventType.contactBlocked || + event.eventType === RealtimeEventType.contactUnblocked + ) { + const blocked = event.eventType === RealtimeEventType.contactBlocked; + queryClient + .getQueryCache() + .findAll({ + predicate: (query) => + query.queryKey[0] === 'ws' && + query.queryKey[1] === workspaceId && + query.queryKey[2] === 'contacts' && + query.queryKey[3] === 'detail' && + query.queryKey[4] === event.data.contactId, + }) + .forEach((query) => { + queryClient.setQueryData( + query.queryKey, + (old: { blockedAt: string | null } | undefined) => + applyContactDetailBlockState(old, blocked), + ); + }); + } + break; + } + + case RealtimeEventType.conversationCreated: { + // Untyped payload — invalidate rather than splice (see apply-conversation-events.ts). + queryClient.invalidateQueries({ queryKey: ['ws', workspaceId, 'conversations', 'list'] }); + break; + } + + case RealtimeEventType.notifyExportResult: + // No UI surface for export jobs on mobile yet — intentionally ignored. + break; + + default: + break; + } + }, + [queryClient, workspaceId], + ); +} diff --git a/src/stores/use-auth-store.test.ts b/src/stores/use-auth-store.test.ts new file mode 100644 index 0000000..7f143cf --- /dev/null +++ b/src/stores/use-auth-store.test.ts @@ -0,0 +1,38 @@ +import { useAuthStore } from '@/stores/use-auth-store'; + +const testUser = { id: 'u1', email: 'a@b.com', name: 'A B', mustChangePassword: false }; + +beforeEach(() => { + useAuthStore.setState({ status: 'pending', user: null }); +}); + +describe('useAuthStore', () => { + it('starts pending with no user', () => { + expect(useAuthStore.getState().status).toBe('pending'); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it('setSignedIn sets status signed-in and stores the user', () => { + useAuthStore.getState().setSignedIn(testUser); + expect(useAuthStore.getState().status).toBe('signed-in'); + expect(useAuthStore.getState().user).toEqual(testUser); + }); + + it('setSignedOut clears the user and sets status signed-out', () => { + useAuthStore.getState().setSignedIn(testUser); + useAuthStore.getState().setSignedOut(); + expect(useAuthStore.getState().status).toBe('signed-out'); + expect(useAuthStore.getState().user).toBeNull(); + }); + + it('setMustChangePassword updates the flag on an existing user', () => { + useAuthStore.getState().setSignedIn(testUser); + useAuthStore.getState().setMustChangePassword(true); + expect(useAuthStore.getState().user?.mustChangePassword).toBe(true); + }); + + it('setMustChangePassword is a no-op when there is no signed-in user', () => { + useAuthStore.getState().setMustChangePassword(true); + expect(useAuthStore.getState().user).toBeNull(); + }); +}); diff --git a/src/stores/use-auth-store.ts b/src/stores/use-auth-store.ts new file mode 100644 index 0000000..b4ca00c --- /dev/null +++ b/src/stores/use-auth-store.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; + +export interface AuthUser { + id: string; + email: string; + name: string | null; + mustChangePassword: boolean; +} + +export type AuthStatus = 'pending' | 'signed-out' | 'signed-in'; + +/** + * Client-state store for the auth session. `status` starts 'pending' so the root layout's + * bootstrap gate can hold the splash screen until `getSession` resolves — see api/auth-endpoints.ts. + * Token storage lives in SecureStore (api/auth-token.ts), not here; this store only holds the + * resolved session shape the UI reads from. + */ +interface AuthStoreState { + status: AuthStatus; + user: AuthUser | null; + setSignedIn: (user: AuthUser) => void; + setSignedOut: () => void; + setMustChangePassword: (mustChangePassword: boolean) => void; +} + +export const useAuthStore = create<AuthStoreState>((set) => ({ + status: 'pending', + user: null, + setSignedIn: (user) => set({ status: 'signed-in', user }), + setSignedOut: () => set({ status: 'signed-out', user: null }), + setMustChangePassword: (mustChangePassword) => + set((state) => (state.user ? { user: { ...state.user, mustChangePassword } } : state)), +})); diff --git a/src/stores/use-settings-store.test.ts b/src/stores/use-settings-store.test.ts new file mode 100644 index 0000000..ea63afd --- /dev/null +++ b/src/stores/use-settings-store.test.ts @@ -0,0 +1,32 @@ +import { useSettingsStore } from '@/stores/use-settings-store'; + +// Hoisted above the import above by babel-jest regardless of source order. +jest.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], +})); + +beforeEach(() => { + useSettingsStore.setState({ themePreference: 'system', language: 'en', pushEnabled: false }); +}); + +describe('useSettingsStore', () => { + it('defaults themePreference to system and pushEnabled to false', () => { + expect(useSettingsStore.getState().themePreference).toBe('system'); + expect(useSettingsStore.getState().pushEnabled).toBe(false); + }); + + it('setThemePreference updates the preference', () => { + useSettingsStore.getState().setThemePreference('dark'); + expect(useSettingsStore.getState().themePreference).toBe('dark'); + }); + + it('setLanguage is a pure setter with no side effects', () => { + useSettingsStore.getState().setLanguage('vi'); + expect(useSettingsStore.getState().language).toBe('vi'); + }); + + it('setPushEnabled updates the flag', () => { + useSettingsStore.getState().setPushEnabled(true); + expect(useSettingsStore.getState().pushEnabled).toBe(true); + }); +}); diff --git a/src/stores/use-settings-store.ts b/src/stores/use-settings-store.ts new file mode 100644 index 0000000..a7425c8 --- /dev/null +++ b/src/stores/use-settings-store.ts @@ -0,0 +1,54 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Localization from 'expo-localization'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { resolveLocale } from '@/i18n/locales'; +import type { SupportedLanguage } from '@/i18n/locales'; + +export type ThemePreference = 'system' | 'light' | 'dark'; + +interface SettingsStoreState { + themePreference: ThemePreference; + language: SupportedLanguage; + /** Persisted user *intent* for push notifications — not the effective permission state, which + * can be revoked in OS Settings independently of this flag. See settings screen for how the two + * combine. */ + pushEnabled: boolean; + setThemePreference: (preference: ThemePreference) => void; + setLanguage: (language: SupportedLanguage) => void; + setPushEnabled: (enabled: boolean) => void; +} + +export const useSettingsStore = create<SettingsStoreState>()( + persist( + (set) => ({ + themePreference: 'system', + language: resolveLocale(Localization.getLocales()[0]?.languageTag), + pushEnabled: false, + setThemePreference: (themePreference) => set({ themePreference }), + // Pure setter — i18next/dayjs/RTL side effects live in `changeAppLanguage` + // (src/i18n/apply-language.ts), which calls this and then applies them. + setLanguage: (language) => set({ language }), + setPushEnabled: (pushEnabled) => set({ pushEnabled }), + }), + { + name: 'settings-store', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); + +/** Resolves once the persisted settings-store snapshot has been read from AsyncStorage (or + * immediately, if hydration already finished before this was called). */ +export function waitForSettingsHydration(): Promise<void> { + if (useSettingsStore.persist.hasHydrated()) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const unsubscribe = useSettingsStore.persist.onFinishHydration(() => { + unsubscribe(); + resolve(); + }); + }); +} diff --git a/src/stores/use-workspace-store.test.ts b/src/stores/use-workspace-store.test.ts new file mode 100644 index 0000000..f780070 --- /dev/null +++ b/src/stores/use-workspace-store.test.ts @@ -0,0 +1,22 @@ +import { useWorkspaceStore } from '@/stores/use-workspace-store'; + +beforeEach(() => { + useWorkspaceStore.setState({ currentWorkspaceId: null }); +}); + +describe('useWorkspaceStore', () => { + it('starts with no current workspace', () => { + expect(useWorkspaceStore.getState().currentWorkspaceId).toBeNull(); + }); + + it('setCurrentWorkspaceId updates the id', () => { + useWorkspaceStore.getState().setCurrentWorkspaceId('w1'); + expect(useWorkspaceStore.getState().currentWorkspaceId).toBe('w1'); + }); + + it('setCurrentWorkspaceId can clear back to null', () => { + useWorkspaceStore.getState().setCurrentWorkspaceId('w1'); + useWorkspaceStore.getState().setCurrentWorkspaceId(null); + expect(useWorkspaceStore.getState().currentWorkspaceId).toBeNull(); + }); +}); diff --git a/src/stores/use-workspace-store.ts b/src/stores/use-workspace-store.ts new file mode 100644 index 0000000..1963185 --- /dev/null +++ b/src/stores/use-workspace-store.ts @@ -0,0 +1,46 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +/** + * Client-state store for the currently selected workspace. This is deliberately client-only + * state (not server state) — the actual list of workspaces the user belongs to comes from + * TanStack Query (`features/workspaces/api/use-workspaces.ts`). + * + * `currentWorkspaceId` mirrors the bigint-as-string id convention — ids are bigint-as-string + * throughout the API. Persisted so the app reopens on the last-selected workspace instead of + * forcing the picker every launch. + */ +interface WorkspaceStoreState { + currentWorkspaceId: string | null; + setCurrentWorkspaceId: (workspaceId: string | null) => void; +} + +export const useWorkspaceStore = create<WorkspaceStoreState>()( + persist( + (set) => ({ + currentWorkspaceId: null, + setCurrentWorkspaceId: (workspaceId) => set({ currentWorkspaceId: workspaceId }), + }), + { + name: 'workspace-store', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +); + +/** Resolves once the persisted workspace-store snapshot has been read from AsyncStorage (or + * immediately, if hydration already finished before this was called). Without gating on this, + * cold start reads `currentWorkspaceId === null` before hydration completes and bounces an + * already-signed-in, already-workspace-selected user to the workspace picker. */ +export function waitForWorkspaceHydration(): Promise<void> { + if (useWorkspaceStore.persist.hasHydrated()) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const unsubscribe = useWorkspaceStore.persist.onFinishHydration(() => { + unsubscribe(); + resolve(); + }); + }); +} diff --git a/src/theme/color-utils.test.ts b/src/theme/color-utils.test.ts new file mode 100644 index 0000000..6e895b4 --- /dev/null +++ b/src/theme/color-utils.test.ts @@ -0,0 +1,50 @@ +import { alphaTokens, withAlpha } from '@/theme/color-utils'; + +describe('withAlpha', () => { + it('converts a 6-digit hex color into an rgba string', () => { + expect(withAlpha('#3c6df0', 0.5)).toBe('rgba(60, 109, 240, 0.5)'); + }); + + it('handles pure black and pure white', () => { + expect(withAlpha('#000000', 1)).toBe('rgba(0, 0, 0, 1)'); + expect(withAlpha('#ffffff', 0)).toBe('rgba(255, 255, 255, 0)'); + }); + + it('is case-insensitive on hex digits', () => { + expect(withAlpha('#3C6DF0', 0.5)).toBe(withAlpha('#3c6df0', 0.5)); + }); + + it('clamps alpha above 1 down to 1', () => { + expect(withAlpha('#112233', 2)).toBe('rgba(17, 34, 51, 1)'); + }); + + it('clamps alpha below 0 up to 0', () => { + expect(withAlpha('#112233', -1)).toBe('rgba(17, 34, 51, 0)'); + }); + + it('throws on a 3-digit shorthand hex', () => { + expect(() => withAlpha('#fff', 0.5)).toThrow(); + }); + + it('throws on an 8-digit hex-with-alpha string', () => { + expect(() => withAlpha('#3c6df0ff', 0.5)).toThrow(); + }); + + it('throws on a named color', () => { + expect(() => withAlpha('blue', 0.5)).toThrow(); + }); +}); + +describe('alphaTokens', () => { + it('defines every documented alpha intent', () => { + expect(alphaTokens).toEqual({ + hover: 0.06, + pressed: 0.1, + soft: 0.12, + softDark: 0.18, + disabled: 0.4, + scrim: 0.4, + scrimDark: 0.6, + }); + }); +}); diff --git a/src/theme/color-utils.ts b/src/theme/color-utils.ts new file mode 100644 index 0000000..e7ba956 --- /dev/null +++ b/src/theme/color-utils.ts @@ -0,0 +1,37 @@ +/** + * Color helpers for the token system. `withAlpha` replaces ad-hoc `${hex}1a`-style string + * concatenation with a real alpha channel, so overlay/soft/scrim tokens can be derived from a + * single base hex value instead of hand-picking a second hex per scheme. + */ + +const HEX_PATTERN = /^#([0-9a-fA-F]{6})$/; + +/** Parses a 6-digit `#rrggbb` hex string and returns an `rgba(r, g, b, alpha)` string. Throws on + * anything else (3-digit shorthand, 8-digit hex-with-alpha, named colors) — callers only ever + * feed this our own token hex values, so a loud failure beats silently returning a wrong color. */ +export function withAlpha(hex: string, alpha: number): string { + const match = HEX_PATTERN.exec(hex); + if (!match) { + throw new Error(`withAlpha: expected a 6-digit hex color like "#3c6df0", got "${hex}"`); + } + + const intValue = parseInt(match[1], 16); + const r = (intValue >> 16) & 255; + const g = (intValue >> 8) & 255; + const b = intValue & 255; + const clampedAlpha = Math.min(1, Math.max(0, alpha)); + + return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})`; +} + +/** Shared alpha values for overlay-style states (hover/press/soft fills/scrims), so call sites + * pick a named intent instead of a magic decimal. */ +export const alphaTokens = { + hover: 0.06, + pressed: 0.1, + soft: 0.12, + softDark: 0.18, + disabled: 0.4, + scrim: 0.4, + scrimDark: 0.6, +} as const; diff --git a/src/theme/motion.ts b/src/theme/motion.ts new file mode 100644 index 0000000..f107f6b --- /dev/null +++ b/src/theme/motion.ts @@ -0,0 +1,13 @@ +/** + * A `useReducedMotion` hook so components can short-circuit springs/entrance animations for + * users with the OS "reduce motion" accessibility setting on, without importing reanimated + * directly everywhere. + */ +import { useReducedMotion as useReanimatedReducedMotion } from 'react-native-reanimated'; + +/** Wraps reanimated's device-level reduced-motion signal. Components should check this before + * kicking off a spring/entrance animation and fall back to an instant state change (or a plain + * opacity fade at `durations.instant`) when it's `true`. */ +export function useReducedMotion(): boolean { + return useReanimatedReducedMotion(); +} diff --git a/src/theme/navigation-theme.ts b/src/theme/navigation-theme.ts new file mode 100644 index 0000000..4983032 --- /dev/null +++ b/src/theme/navigation-theme.ts @@ -0,0 +1,24 @@ +import { DarkTheme, DefaultTheme, type Theme } from 'expo-router'; + +import { colorTokens } from './tokens'; +import type { ResolvedScheme } from './use-theme'; + +/** Feeds our design tokens into react-navigation's ThemeProvider so headers, tab bars, and + * native-stack chrome match the app's palette instead of react-navigation's defaults. */ +export function getNavigationTheme(scheme: ResolvedScheme): Theme { + const base = scheme === 'dark' ? DarkTheme : DefaultTheme; + const colors = colorTokens[scheme]; + + return { + ...base, + colors: { + ...base.colors, + primary: colors.brand, + background: colors.bg, + card: colors.surface1, + text: colors.textPrimary, + border: colors.borderSubtle, + notification: colors.danger, + }, + }; +} diff --git a/src/theme/tokens.test.ts b/src/theme/tokens.test.ts new file mode 100644 index 0000000..ae31759 --- /dev/null +++ b/src/theme/tokens.test.ts @@ -0,0 +1,159 @@ +import { colorTokens, radiusTokens, spacingTokens, typographyTokens } from '@/theme/tokens'; + +const NEW_SEMANTIC_COLOR_KEYS = [ + 'bg', + 'surface0', + 'surface1', + 'surface2', + 'borderSubtle', + 'borderStrong', + 'textPrimary', + 'textSecondary', + 'textTertiary', + 'textInverse', + 'brand', + 'brandStrong', + 'brandSoft', + 'onBrand', + 'bubbleIn', + 'bubbleInText', + 'bubbleOut', + 'bubbleOutText', + 'bubbleBot', + 'bubbleBotText', + 'bubbleBotAccent', + 'success', + 'successSoft', + 'warning', + 'warningSoft', + 'danger', + 'dangerSoft', + 'info', + 'infoSoft', + 'scrim', +] as const; + +// Removed tokens: `unreadDot`, `bubbleOutbound(Text)`, `bubbleInbound(Text)`; a later cleanup +// removed the remaining flat-key aliases (`background`, `surface`, `surfaceElevated`, `border`, +// `text`, `primary`, `primaryForeground`) once every call site migrated to the semantic keys +// above — see tokens.ts's doc comment. +const CHANNEL_KEYS = [ + 'messenger', + 'instagram', + 'whatsapp', + 'webchat', + 'email', + 'smtp', + 'sms', + 'zalo', + 'telegram', + 'tiktok', + 'omnichannel', +] as const; + +describe('colorTokens', () => { + it.each(['light', 'dark'] as const)('defines every new semantic key for %s', (scheme) => { + for (const key of NEW_SEMANTIC_COLOR_KEYS) { + expect(colorTokens[scheme]).toHaveProperty(key); + expect(typeof colorTokens[scheme][key]).toBe('string'); + } + }); + + it.each(['light', 'dark'] as const)('defines exactly the 11 channel keys for %s', (scheme) => { + expect(Object.keys(colorTokens[scheme].channel).sort()).toEqual([...CHANNEL_KEYS].sort()); + }); + + it('aliases smtp to email and webchat to brand in both schemes', () => { + for (const scheme of ['light', 'dark'] as const) { + expect(colorTokens[scheme].channel.smtp).toBe(colorTokens[scheme].channel.email); + expect(colorTokens[scheme].channel.webchat).toBe(colorTokens[scheme].brand); + } + }); + + it.each(['light', 'dark'] as const)('defines a 6-color avatar palette for %s', (scheme) => { + expect(colorTokens[scheme].avatarPalette).toHaveLength(6); + }); + + it.each(['light', 'dark'] as const)( + 'no longer exposes any deprecated flat-key alias for %s', + (scheme) => { + const tokens = colorTokens[scheme] as Record<string, unknown>; + // Removed tokens: + expect(tokens.unreadDot).toBeUndefined(); + expect(tokens.bubbleOutbound).toBeUndefined(); + expect(tokens.bubbleOutboundText).toBeUndefined(); + expect(tokens.bubbleInbound).toBeUndefined(); + expect(tokens.bubbleInboundText).toBeUndefined(); + // Later cleanup removed the rest once every call site migrated to the semantic keys above: + expect(tokens.background).toBeUndefined(); + expect(tokens.surface).toBeUndefined(); + expect(tokens.surfaceElevated).toBeUndefined(); + expect(tokens.border).toBeUndefined(); + expect(tokens.text).toBeUndefined(); + expect(tokens.primary).toBeUndefined(); + expect(tokens.primaryForeground).toBeUndefined(); + }, + ); +}); + +describe('spacingTokens', () => { + it('defines the full spacing scale', () => { + expect(spacingTokens).toEqual({ + xxs: 2, + xs: 4, + sm: 8, + ms: 10, + base: 12, + md: 16, + ml: 20, + lg: 24, + xl: 32, + xxl: 48, + }); + }); +}); + +describe('radiusTokens', () => { + it('defines the full radius scale', () => { + expect(radiusTokens).toEqual({ + xs: 6, + sm: 8, + md: 12, + lg: 16, + xl: 20, + bubble: 18, + full: 999, + }); + }); +}); + +describe('typographyTokens', () => { + it('defines every variant with a maxFontSizeMultiplier', () => { + const variants = [ + 'display', + 'heading', + 'title', + 'subtitle', + 'body', + 'bodyStrong', + 'callout', + 'caption', + 'label', + 'micro', + ] as const; + + for (const variant of variants) { + expect(typographyTokens[variant].maxFontSizeMultiplier).toBeGreaterThan(1); + } + }); + + it('sizes display largest and micro smallest', () => { + expect(typographyTokens.display.fontSize).toBeGreaterThan(typographyTokens.heading.fontSize); + expect(typographyTokens.micro.fontSize).toBeLessThan(typographyTokens.caption.fontSize); + }); + + it('marks label as uppercase with letter spacing', () => { + expect(typographyTokens.label.textTransform).toBe('uppercase'); + expect(typographyTokens.label.letterSpacing).toBeGreaterThan(0); + }); +}); diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts new file mode 100644 index 0000000..d8f2d57 --- /dev/null +++ b/src/theme/tokens.ts @@ -0,0 +1,282 @@ +/** + * Design tokens — single source of truth for color/spacing/radius/elevation/typography/motion. + * See src/theme/use-theme.ts for the hook that resolves these against the active scheme, and + * src/theme/navigation-theme.ts for the react-navigation adapter. + * + * Style direction: "Layered Workbench" — a tinted-neutral canvas with two raised surface levels, + * hairline borders instead of heavy shadows, a strong type hierarchy, and color reserved for + * meaning (channel identity, unread state, bot state, semantic status) rather than decoration. + */ + +import { withAlpha } from './color-utils'; + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +const lightBase = { + bg: '#f6f5f2', + surface0: '#efeee9', + surface1: '#ffffff', + surface2: '#fbfaf8', + borderSubtle: '#e5e2da', + borderStrong: '#cfcabd', + textPrimary: '#1c1b17', + textSecondary: '#615c4f', + // WCAG AA (4.5:1) against `bg` (#f6f5f2) requires darkening past the original #948d7c + // (~3.0:1) — #767063 is the same warm-neutral hue, darkened just enough to clear 4.5:1 + // (measured ~4.51:1). + textTertiary: '#767063', + textInverse: '#ffffff', + brand: '#3c6df0', + brandStrong: '#2851c9', + onBrand: '#ffffff', + bubbleIn: '#ffffff', + bubbleInText: '#1c1b17', + bubbleOut: '#3c6df0', + bubbleOutText: '#ffffff', + bubbleBot: '#f1ecff', + bubbleBotText: '#2f1f66', + bubbleBotAccent: '#7c4dff', + success: '#12855a', + warning: '#b4650a', + danger: '#d92d20', + info: '#2464c9', +} as const; + +const darkBase = { + bg: '#131210', + surface0: '#1b1a17', + surface1: '#211f1b', + surface2: '#28261f', + borderSubtle: '#332f27', + borderStrong: '#4a4536', + textPrimary: '#f4f2ec', + textSecondary: '#b9b3a2', + textTertiary: '#847d6b', + textInverse: '#1c1b17', + brand: '#7d9eff', + brandStrong: '#a4bcff', + onBrand: '#0e152b', + bubbleIn: '#28261f', + bubbleInText: '#f4f2ec', + bubbleOut: '#3c5fd9', + bubbleOutText: '#ffffff', + bubbleBot: '#2a2145', + bubbleBotText: '#e4d9ff', + bubbleBotAccent: '#a184ff', + success: '#4fd39a', + warning: '#f2a93c', + danger: '#f97066', + info: '#7db2ff', +} as const; + +/** 11-key channel identity map — every contactInbox `channel` value the backend can send. `smtp` + * aliases `email`'s color (same medium, different transport), `webchat` aliases `brand` (it *is* + * our own product surface, so it borrows the brand color rather than getting an arbitrary one). */ +const lightChannel = { + messenger: '#0866ff', + instagram: '#e1306c', + whatsapp: '#25d366', + webchat: lightBase.brand, + email: '#6b6355', + smtp: '#6b6355', + sms: '#12855a', + zalo: '#0068ff', + telegram: '#26a5e4', + tiktok: '#010101', + omnichannel: '#7c4dff', +} as const; + +const darkChannel = { + messenger: '#4a9eff', + instagram: '#f472a3', + whatsapp: '#4ade80', + webchat: darkBase.brand, + email: '#b9b3a2', + smtp: '#b9b3a2', + sms: '#4fd39a', + zalo: '#5c9dff', + telegram: '#6fc3ef', + tiktok: '#f4f2ec', + omnichannel: '#a184ff', +} as const; + +/** Deterministic pastel fills for avatar-fallback tinting (initials on a color picked by hashing + * the contact id/name — see avatar.tsx) — same 6 hues in both schemes, just retuned + * for contrast against each canvas. */ +const lightAvatarPalette = [ + '#ffd6d1', // coral + '#ffe8b8', // amber + '#d7f0d0', // sage + '#c9ecec', // teal + '#cfe0ff', // periwinkle + '#ead6ff', // lilac +] as const; + +const darkAvatarPalette = [ + '#5c2e29', // coral + '#5c4a1f', // amber + '#2c4a26', // sage + '#204a4a', // teal + '#26355c', // periwinkle + '#43285c', // lilac +] as const; + +type ColorBase = Record<keyof typeof lightBase, string>; +type ChannelMap = Record<keyof typeof lightChannel, string>; + +function buildColorTokens(base: ColorBase, channel: ChannelMap, avatarPalette: readonly string[]) { + return { + ...base, + brandSoft: withAlpha(base.brand, 0.14), + successSoft: withAlpha(base.success, 0.14), + warningSoft: withAlpha(base.warning, 0.14), + dangerSoft: withAlpha(base.danger, 0.14), + infoSoft: withAlpha(base.info, 0.14), + scrim: withAlpha('#000000', 0.5), + channel, + avatarPalette, + }; +} + +export const colorTokens = { + light: buildColorTokens(lightBase, lightChannel, lightAvatarPalette), + dark: buildColorTokens(darkBase, darkChannel, darkAvatarPalette), +} as const; + +// --------------------------------------------------------------------------- +// Spacing +// --------------------------------------------------------------------------- + +export const spacingTokens = { + xxs: 2, + xs: 4, + sm: 8, + ms: 10, + base: 12, + md: 16, + ml: 20, + lg: 24, + xl: 32, + xxl: 48, +} as const; + +// --------------------------------------------------------------------------- +// Radius +// --------------------------------------------------------------------------- + +/** + * NOTE on the `sm/md/lg/full` rename: the old scale was `sm:4, md:8, lg:16, full:999`. Existing + * callers using `radius.sm + 2` (segmented-tabs pill, badge padding-adjacent math) or bare + * `radius.md`/`radius.sm` (message bubbles, buttons, inputs, search bar) all read reasonably + * with the new, slightly larger values below — nothing goes from a hairline radius to a full + * pill or vice versa. `radius.full` is unchanged. See report for the full grep audit. + */ +export const radiusTokens = { + xs: 6, + sm: 8, + md: 12, + lg: 16, + xl: 20, + bubble: 18, + full: 999, +} as const; + +// --------------------------------------------------------------------------- +// Elevation +// --------------------------------------------------------------------------- + +interface ElevationToken { + shadowColor: string; + shadowOpacity: number; + shadowRadius: number; + shadowOffset: { width: number; height: number }; + elevation: number; +} + +function buildElevationTokens(shadowColor: string, opacityMultiplier: number) { + const levels: readonly [number, number, number][] = [ + [0, 0, 0], // 0 — flush with the canvas, no shadow + [0.06, 3, 1], // 1 — subtle lift (rows, chips) + [0.1, 8, 2], // 2 — floating surfaces (cards, sheets at rest) + [0.16, 16, 4], // 3 — overlays (modals, active sheets) + ]; + + return levels.map(([opacity, shadowRadius, elevation]): ElevationToken => ({ + shadowColor, + shadowOpacity: opacity * opacityMultiplier, + shadowRadius, + shadowOffset: { width: 0, height: elevation > 0 ? Math.ceil(elevation / 2) : 0 }, + elevation, + })) as [ElevationToken, ElevationToken, ElevationToken, ElevationToken]; +} + +export const elevationTokens = { + light: buildElevationTokens('#1c1b17', 1), + // Dark surfaces already sit on a dark canvas, so shadows read as noise unless softened — + // halve the opacity relative to light. + dark: buildElevationTokens('#000000', 0.5), +} as const; + +// --------------------------------------------------------------------------- +// Typography +// --------------------------------------------------------------------------- + +interface TypographyToken { + fontSize: number; + lineHeight: number; + fontWeight: '400' | '500' | '600' | '700'; + maxFontSizeMultiplier: number; + textTransform?: 'uppercase'; + letterSpacing?: number; + fontVariant?: readonly ['tabular-nums']; +} + +/** + * NOTE on `heading`: the old spec was 28/34/700. The new redesign spec sizes `heading` at + * 24/30/700 (one step down, `display` now owns the 32/38/700 slot old `heading` was closest to). + * Any screen currently relying on `heading` for a large page title will render ~4pt smaller — + * intentional per the new hierarchy (display > heading), flagged in the report rather than + * silently left as a surprise. + */ +export const typographyTokens = { + display: { fontSize: 32, lineHeight: 38, fontWeight: '700', maxFontSizeMultiplier: 1.3 }, + heading: { fontSize: 24, lineHeight: 30, fontWeight: '700', maxFontSizeMultiplier: 1.3 }, + title: { fontSize: 20, lineHeight: 26, fontWeight: '600', maxFontSizeMultiplier: 1.4 }, + subtitle: { fontSize: 17, lineHeight: 22, fontWeight: '600', maxFontSizeMultiplier: 1.4 }, + body: { fontSize: 16, lineHeight: 22, fontWeight: '400', maxFontSizeMultiplier: 1.6 }, + bodyStrong: { fontSize: 16, lineHeight: 22, fontWeight: '600', maxFontSizeMultiplier: 1.6 }, + callout: { fontSize: 15, lineHeight: 20, fontWeight: '400', maxFontSizeMultiplier: 1.6 }, + caption: { fontSize: 13, lineHeight: 18, fontWeight: '400', maxFontSizeMultiplier: 1.6 }, + label: { + fontSize: 12, + lineHeight: 16, + fontWeight: '600', + maxFontSizeMultiplier: 1.6, + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + micro: { + fontSize: 11, + lineHeight: 14, + fontWeight: '500', + maxFontSizeMultiplier: 1.6, + fontVariant: ['tabular-nums'], + }, +} as const satisfies Record<string, TypographyToken>; + +// --------------------------------------------------------------------------- +// Motion +// --------------------------------------------------------------------------- + +export const motionDurations = { + instant: 80, + fast: 120, + base: 200, + slow: 320, + enter: 240, + exit: 160, +} as const; + +export const pressScale = 0.97; diff --git a/src/theme/typography.ts b/src/theme/typography.ts new file mode 100644 index 0000000..524a462 --- /dev/null +++ b/src/theme/typography.ts @@ -0,0 +1,52 @@ +/** + * Display-family font resolution. Chrome text (headings, titles, labels — anything using the + * `display`/`heading`/`title`/`subtitle` typography variants at weight 600/700) uses Plus Jakarta + * Sans; body/user-generated content stays on the system font so CJK/Arabic/Hebrew glyphs never + * mix a Latin-only webfont with a system CJK/RTL fallback mid-string. + * + * Fonts must be loaded via `useFonts` from `@expo-google-fonts/plus-jakarta-sans` (see + * src/app/_layout.tsx) before this is used to render — `resolveDisplayFamily` only picks the + * *name*, it doesn't load anything itself. + */ +import { + PlusJakartaSans_600SemiBold, + PlusJakartaSans_700Bold, +} from '@expo-google-fonts/plus-jakarta-sans'; + +/** Language prefixes (BCP-47 primary subtag) whose scripts should never be forced onto a + * Latin-only display face — Arabic, Hebrew, Japanese, Chinese. Returns `undefined` for these so + * callers fall back to the OS system font, which already ships full script coverage. */ +const SYSTEM_FONT_LANGUAGE_PREFIXES = ['ar', 'he', 'ja', 'zh'] as const; + +export type DisplayFontWeight = '600' | '700'; + +const DISPLAY_FONT_FAMILIES: Record<DisplayFontWeight, string> = { + '600': 'PlusJakartaSans_600SemiBold', + '700': 'PlusJakartaSans_700Bold', +}; + +/** Ensures the font-loading side effect above is retained (and the values are the actual + * `useFonts` map keys) — never referenced directly, `useFonts({ PlusJakartaSans_600SemiBold, ... + * })` in _layout.tsx uses the named imports instead. */ +void PlusJakartaSans_600SemiBold; +void PlusJakartaSans_700Bold; + +/** + * Resolves the font-family name to use for display-weight chrome text in the given language. + * Returns `undefined` (meaning: don't override `fontFamily`, let the system font render) for + * languages whose scripts Plus Jakarta Sans doesn't cover well; otherwise returns the loaded + * Plus Jakarta Sans family name for the given weight. + */ +export function resolveDisplayFamily( + language: string, + weight: DisplayFontWeight = '700', +): string | undefined { + const primarySubtag = language.toLowerCase().split('-')[0]; + const isSystemFontLanguage = SYSTEM_FONT_LANGUAGE_PREFIXES.some( + (prefix) => primarySubtag === prefix, + ); + + if (isSystemFontLanguage) return undefined; + + return DISPLAY_FONT_FAMILIES[weight]; +} diff --git a/src/theme/use-theme.ts b/src/theme/use-theme.ts new file mode 100644 index 0000000..19971f8 --- /dev/null +++ b/src/theme/use-theme.ts @@ -0,0 +1,64 @@ +import { useColorScheme } from 'react-native'; + +import { useSettingsStore } from '@/stores/use-settings-store'; + +import { + colorTokens, + elevationTokens, + motionDurations, + pressScale, + radiusTokens, + spacingTokens, + typographyTokens, +} from './tokens'; + +export type ResolvedScheme = 'light' | 'dark'; + +/** Resolves the settings-store theme preference against the OS scheme ('system' ⊕ override). */ +export function useResolvedScheme(): ResolvedScheme { + const themePreference = useSettingsStore((state) => state.themePreference); + const systemScheme = useColorScheme(); + + if (themePreference === 'system') { + return systemScheme === 'dark' ? 'dark' : 'light'; + } + return themePreference; +} + +/** Builds the full resolved theme object for one scheme. Colors/radius/spacing/typography are + * already fully static per scheme in tokens.ts (soft/alpha variants are precomputed there via + * `withAlpha`), so this is just an assembly step — but it's a function (not an inline literal) so + * `THEMES` below can build both variants with one call each. */ +function buildTheme(scheme: ResolvedScheme) { + return { + scheme, + colors: colorTokens[scheme], + spacing: spacingTokens, + radius: radiusTokens, + elevation: elevationTokens[scheme], + typography: typographyTokens, + motion: { + durations: motionDurations, + pressScale, + }, + }; +} + +/** Built once at module load — not per render, not per hook call — so `useTheme()` can return a + * stable reference per scheme instead of a fresh object every render (which would break + * `useMemo`/`useCallback` dependency arrays keyed on the theme object). */ +const THEMES = { + light: buildTheme('light'), + dark: buildTheme('dark'), +} as const; + +export type Theme = (typeof THEMES)[ResolvedScheme]; + +/** Resolves the full token set for the active scheme. Use this in components instead of + * importing colorTokens directly, so theme overrides are respected everywhere. Returns the same + * object reference across renders as long as the resolved scheme doesn't change. */ +export function useTheme(): Theme { + const scheme = useResolvedScheme(); + + return THEMES[scheme]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7ff4f8d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "types": ["jest"], + "paths": { + "@/*": ["./src/*"], + "@/assets/*": ["./assets/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +}