mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f38b6f14fa | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
2cbe9e9c55 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
fd38b303f0 | ||
|
|
387b242aea |
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-30-ios-extension-version-verification
|
||||||
|
date: 2026-08-30
|
||||||
|
status: promoted
|
||||||
|
scope: iOS release artifact verification
|
||||||
|
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
|
||||||
|
destination: .codex/skills/release-mobile/SKILL.md
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
The signed iOS build logged that the widget extension build number 44 did not
|
||||||
|
match the containing app build number 45, even though export completed.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
|
||||||
|
archive log. Extract the resulting IPA with `ditto`, then read the containing app
|
||||||
|
and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
The archive warning described an intermediate state and was not authoritative
|
||||||
|
for the exported package. Direct IPA inspection showed both the app and
|
||||||
|
`LiveDrawWidget.appex` at version 1.0.6 build 45.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
For every iOS release, inspect version and build values in the exported app and
|
||||||
|
all packaged extensions. Reject or rebuild only when the final IPA values differ;
|
||||||
|
do not accept or reject a release from archive warnings alone.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||||
|
both packaged plists. `codesign --verify --deep --strict` passed for the app, and
|
||||||
|
the verified IPA SHA-256 was
|
||||||
|
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
name = "implementation"
|
name = "implementation"
|
||||||
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
|
description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||||
model = "gpt-5.6-terra"
|
model = "gpt-5.6-terra"
|
||||||
developer_instructions = """
|
developer_instructions = """
|
||||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||||
skill. Implement only the delegated objective and preserve unrelated work. Update
|
skill. Own the continuous execution unit: perform scoped discovery,
|
||||||
maintained contract documentation and add focused tests when the contract changes.
|
implementation, focused tests, and iteration in this handoff. Implement only the
|
||||||
Run the required focused checks and return changed files, exact results,
|
delegated objective and preserve unrelated work. Update maintained contract
|
||||||
assumptions, and blockers. Stop for missing authority or a materially ambiguous
|
documentation and add focused tests when the contract changes. Run the required
|
||||||
product choice. Do not expand scope or perform external, destructive, production,
|
focused checks and return changed files, exact results, assumptions, and blockers.
|
||||||
release, or provider writes unless the task explicitly authorizes that action.
|
Stop for missing authority or a materially ambiguous product choice. Do not expand
|
||||||
|
scope or perform external, destructive, production, release, or provider writes
|
||||||
|
unless the task explicitly authorizes that action.
|
||||||
"""
|
"""
|
||||||
@@ -19,8 +19,44 @@ so routine release builds must package those checked-in sources. Do not run
|
|||||||
|
|
||||||
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||||
Never infer upload or submission authority from build authorization. Store upload
|
Never infer upload or submission authority from build authorization. Store upload
|
||||||
uses a separately authorized command. Preserve platform signing and local EAS
|
uses a separately authorized command.
|
||||||
versioning. After a build, verify that tracked changes are limited to the expected
|
|
||||||
version metadata, then independently read the artifact version and signature.
|
## Upload authorization gate
|
||||||
Report the artifact, version/build number, upload result when authorized, and
|
|
||||||
read-back state.
|
After an artifact passes verification, stop immediately before the external
|
||||||
|
upload and ask the user for confirmation. The request must identify:
|
||||||
|
|
||||||
|
- the platform, exact artifact path, bundle/package identifier, public version,
|
||||||
|
build number, signature identity, and SHA-256 digest;
|
||||||
|
- the exact destination and resulting state: Google Play internal-track draft or
|
||||||
|
App Store Connect/TestFlight processing;
|
||||||
|
- that the upload does not authorize a public rollout or App Review submission.
|
||||||
|
|
||||||
|
For iOS, inspect the current App Store Connect automatic-distribution behavior
|
||||||
|
before asking. If the build would be distributed automatically, name the exact
|
||||||
|
TestFlight groups or testers in the request and include that effect in the fresh
|
||||||
|
authorization. If the effect cannot be established, stop; changing an automatic-
|
||||||
|
distribution setting also requires separate authorization.
|
||||||
|
|
||||||
|
Even when the original request includes building, releasing, uploading, or
|
||||||
|
submitting, require this fresh confirmation once the exact verified artifact and
|
||||||
|
destination are known. End the request with: `Upload this verified artifact to
|
||||||
|
<destination> now?` A clear affirmative response authorizes one upload of that
|
||||||
|
artifact to that destination, plus only any automatic TestFlight distribution
|
||||||
|
explicitly disclosed in the request. Run `eas submit` with the explicit
|
||||||
|
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
|
||||||
|
select `--latest`. If the result is ambiguous, inspect EAS and the store before
|
||||||
|
doing anything else, do not retry, and ask again before another upload.
|
||||||
|
|
||||||
|
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
|
||||||
|
and the store consoles. A later public rollout, App Review submission, or
|
||||||
|
additional TestFlight distribution requires another fresh authorization.
|
||||||
|
|
||||||
|
Preserve platform signing and local EAS versioning. After a build, verify that
|
||||||
|
tracked changes are limited to the expected version metadata, then independently
|
||||||
|
read the artifact version and signature. For an iOS IPA, inspect every packaged
|
||||||
|
app extension as well as the containing app and require matching
|
||||||
|
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
|
||||||
|
warnings as diagnostic evidence, not the final artifact state: decide whether to
|
||||||
|
reject or rebuild only after reading the exported IPA itself. Report the artifact,
|
||||||
|
version/build number, upload result when authorized, and store read-back state.
|
||||||
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
|
|||||||
|
|
||||||
# Verify Sauci work
|
# Verify Sauci work
|
||||||
|
|
||||||
Run locally on a non-default branch with the implementation committed.
|
Run locally on a non-default branch with the implementation committed. Use an
|
||||||
|
independent verifier for user-facing, cross-boundary, or high-risk runtime
|
||||||
|
changes. Documentation, mechanical configuration, isolated tests, and low-risk
|
||||||
|
package-local changes may use focused deterministic checks without a verifier.
|
||||||
|
|
||||||
1. Start the stack once with `scripts/dev-local.sh up`; reset fixtures first when
|
1. Package-local deterministic checks do not need a running stack. For runtime
|
||||||
the task changes durable state. Web is `http://127.0.0.1:3000`, admin is
|
verification, reuse a compatible running local stack when available; otherwise
|
||||||
`http://127.0.0.1:3001`, and MCP health is `http://127.0.0.1:3002/health`.
|
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
|
||||||
2. Give a fresh read-only verifier the acceptance criteria. It drives browser
|
first when the task changes durable state. Web is `http://127.0.0.1:3000`,
|
||||||
|
admin is `http://127.0.0.1:3001`, and MCP health is
|
||||||
|
`http://127.0.0.1:3002/health`.
|
||||||
|
2. When independent runtime verification is required, give a fresh read-only
|
||||||
|
verifier the acceptance criteria. It drives browser
|
||||||
surfaces with the repository browser capability, native surfaces with Maestro
|
surfaces with the repository browser capability, native surfaces with Maestro
|
||||||
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
||||||
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
||||||
3. The verifier writes screenshots, traces, video, or response evidence under
|
3. The verifier writes screenshots, traces, video, or response evidence under
|
||||||
`evidence/` and returns only expected, observed, evidence paths, and
|
`evidence/` and returns only expected, observed, evidence paths, and
|
||||||
`works|broken`. It never edits code.
|
`works|broken`. It never edits code.
|
||||||
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and
|
4. If independent verification is broken, fix and use a fresh verifier. Stop
|
||||||
report the unresolved evidence.
|
after three failed rounds and report the unresolved evidence.
|
||||||
5. After the task works, run `npm run verify:fast`, affected E2E, and
|
5. Run focused checks for the affected change. Run affected E2E when the runtime
|
||||||
`npm run verify:full` when local Supabase/Deno are available. Never weaken an
|
path requires it. Run `npm run verify:full` when its prerequisites are
|
||||||
assertion just to pass.
|
available; it already runs `npm run verify:fast`, so do not run both in
|
||||||
|
sequence. Never weaken an assertion just to pass.
|
||||||
6. Open a PR only after both task verification and regression checks pass. Upload
|
6. Open a PR only after both task verification and regression checks pass. Upload
|
||||||
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
||||||
the screenshot plus a video/trace link in the PR body.
|
the screenshot plus a video/trace link in the PR body.
|
||||||
|
|
||||||
A green test suite without an independently observed task outcome is incomplete.
|
A green test suite without an independently observed outcome is incomplete when
|
||||||
|
independent runtime verification is required.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
@@ -23,11 +25,15 @@ apps/mcp
|
|||||||
apps/mobile
|
apps/mobile
|
||||||
apps/supabase
|
apps/supabase
|
||||||
apps/web
|
apps/web
|
||||||
|
!apps/web
|
||||||
|
!apps/web/**
|
||||||
e2e
|
e2e
|
||||||
evidence
|
evidence
|
||||||
etc
|
etc
|
||||||
infra
|
infra
|
||||||
scripts
|
scripts
|
||||||
|
!scripts
|
||||||
|
!scripts/fix-hoisting.js
|
||||||
terraform
|
terraform
|
||||||
.agents
|
.agents
|
||||||
.claude
|
.claude
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
and prove the relevant compatibility contracts still hold. Preserve deployed
|
and prove the relevant compatibility contracts still hold. Preserve deployed
|
||||||
interfaces and supported client behavior; repository checks alone do not
|
interfaces and supported client behavior; repository checks alone do not
|
||||||
prove a live change is safe.
|
prove a live change is safe.
|
||||||
10. Scope work to the task. Route bounded implementation through the project
|
10. The Sol primary agent owns scope, risk classification, acceptance criteria,
|
||||||
`implementation` agent when delegation is useful; the primary agent retains
|
progress steering, final diff and evidence review, and the final answer.
|
||||||
acceptance, review, and final verification. Do not delegate trivial edits.
|
Delegate every substantive repository task to a project agent. Each
|
||||||
|
substantive implementation must use one Terra `implementation` agent as a
|
||||||
|
coherent execution unit, normally combining scoped discovery, implementation,
|
||||||
|
focused tests, and iteration. "Substantive" includes repository exploration,
|
||||||
|
multi-file reasoning, implementation, test execution, or contract/risk
|
||||||
|
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
|
||||||
|
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
|
||||||
|
agent's detailed exploration or checks without a concrete reason. Skip
|
||||||
|
delegation only for trivial conversation, a single obvious lookup, or a
|
||||||
|
mechanical one-line edit whose coordination cost exceeds the work.
|
||||||
11. Use high-effort reviewers only for the risk triggers in
|
11. Use high-effort reviewers only for the risk triggers in
|
||||||
`docs/agents/routing.md`.
|
`docs/agents/routing.md`.
|
||||||
|
12. Agents must not use em dash characters in text they add to this repository.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
|
||||||
|
-- answers every question twice, once about themselves and once guessing their
|
||||||
|
-- partner, and the session only scores once both partners have a complete set.
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_questions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"prompt_self" text NOT NULL UNIQUE,
|
||||||
|
"prompt_guess" text NOT NULL,
|
||||||
|
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
|
||||||
|
"sort_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"is_active" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
|
||||||
|
"question_ids" uuid[] NOT NULL,
|
||||||
|
"score_percent" integer,
|
||||||
|
"completed_at" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_answers" (
|
||||||
|
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"question_id" uuid NOT NULL,
|
||||||
|
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
|
||||||
|
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("session_id", "user_id", "question_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||||
|
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
|
||||||
|
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
|
||||||
|
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
|
||||||
|
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
|
||||||
|
('How do you most like to receive love?', 'How does your partner most like to receive love?',
|
||||||
|
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
|
||||||
|
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
|
||||||
|
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
|
||||||
|
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
|
||||||
|
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
|
||||||
|
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
|
||||||
|
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
|
||||||
|
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
|
||||||
|
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
|
||||||
|
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
|
||||||
|
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
|
||||||
|
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
|
||||||
|
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
|
||||||
|
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
|
||||||
|
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
|
||||||
|
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
|
||||||
|
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
|
||||||
|
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
|
||||||
|
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
|
||||||
|
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||||
@@ -134,6 +134,13 @@
|
|||||||
"when": 1788081480000,
|
"when": 1788081480000,
|
||||||
"tag": "0018_cutover_source_parity",
|
"tag": "0018_cutover_source_parity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168000000,
|
||||||
|
"tag": "0019_quiz",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
|
|||||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||||
import type { DaresRepository } from './domains/dares/repository.js';
|
import type { DaresRepository } from './domains/dares/repository.js';
|
||||||
import { registerDareRoutes } from './domains/dares/routes.js';
|
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||||
|
import type { QuizRepository } from './domains/quiz/repository.js';
|
||||||
|
import { registerQuizRoutes } from './domains/quiz/routes.js';
|
||||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -39,6 +41,7 @@ export interface AppDependencies {
|
|||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
daresRepository?: DaresRepository;
|
daresRepository?: DaresRepository;
|
||||||
|
quizRepository?: QuizRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -126,6 +129,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
|||||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||||
|
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
|
|||||||
export * from '../domains/answers/schema.js';
|
export * from '../domains/answers/schema.js';
|
||||||
export * from '../domains/chat/schema.js';
|
export * from '../domains/chat/schema.js';
|
||||||
export * from '../domains/app-data/schema.js';
|
export * from '../domains/app-data/schema.js';
|
||||||
|
export * from '../domains/quiz/schema.js';
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
import { computeQuizScore, type QuizAnswerPair } from './service.js';
|
||||||
|
import {
|
||||||
|
QuizError,
|
||||||
|
type QuizAnswerInput,
|
||||||
|
type QuizQuestionSummary,
|
||||||
|
type QuizResultPayload,
|
||||||
|
type QuizSessionPayload,
|
||||||
|
type QuizSessionStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface QuizQuestionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizSessionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
status: QuizSessionStatus;
|
||||||
|
question_ids: string[];
|
||||||
|
score_percent: number | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizAnswerRow extends QueryResultRow {
|
||||||
|
user_id: string;
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizRepository {
|
||||||
|
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
|
||||||
|
currentSession(userId: string): Promise<QuizSessionPayload | null>;
|
||||||
|
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
|
||||||
|
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUESTIONS_PER_SESSION = 10;
|
||||||
|
|
||||||
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await work(client);
|
||||||
|
await client.query('commit');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresQuizRepository implements QuizRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
|
constructor(connection: DatabaseConnection) {
|
||||||
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ couple_id: string | null }>(
|
||||||
|
'select couple_id from profiles where id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.couple_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
|
||||||
|
[coupleId, userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
|
||||||
|
const result = await client.query<QuizQuestionRow>(
|
||||||
|
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
|
||||||
|
[questionIds],
|
||||||
|
);
|
||||||
|
return orderByIds(result.rows, questionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPayload(
|
||||||
|
client: Pool | PoolClient,
|
||||||
|
session: QuizSessionRow,
|
||||||
|
userId: string,
|
||||||
|
): Promise<QuizSessionPayload> {
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const [myAnswers, counts] = await Promise.all([
|
||||||
|
client.query<QuizAnswerRow>(
|
||||||
|
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
|
||||||
|
[session.id, userId],
|
||||||
|
),
|
||||||
|
client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[session.id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const coupleId = session.couple_id;
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
|
||||||
|
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
|
||||||
|
const orderedAnswers: QuizAnswerInput[] = session.question_ids
|
||||||
|
.map((questionId) => answersByQuestion.get(questionId))
|
||||||
|
.filter((row): row is QuizAnswerRow => row !== undefined)
|
||||||
|
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
|
||||||
|
|
||||||
|
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
prompt_self: row.prompt_self,
|
||||||
|
prompt_guess: row.prompt_guess,
|
||||||
|
options: row.options,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
status: session.status,
|
||||||
|
created_at: session.created_at.toISOString(),
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
score_percent: session.score_percent,
|
||||||
|
questions: questionSummaries,
|
||||||
|
my_answers: orderedAnswers,
|
||||||
|
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
|
||||||
|
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
|
||||||
|
|
||||||
|
const members = await client.query<{ count: string }>(
|
||||||
|
'select count(*)::text as count from profiles where couple_id = $1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (Number(members.rows[0]?.count ?? 0) < 2) {
|
||||||
|
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = await client.query<{ id: string }>(
|
||||||
|
'select id from quiz_questions where is_active = true order by sort_order, id limit $1',
|
||||||
|
[QUESTIONS_PER_SESSION],
|
||||||
|
);
|
||||||
|
const questionIds = questions.rows.map((row) => row.id);
|
||||||
|
|
||||||
|
// The partial unique index (one active session per couple) is the source of
|
||||||
|
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
|
||||||
|
// lets the loser fall through to reading the winner's row instead of erroring.
|
||||||
|
const inserted = await client.query<QuizSessionRow>(
|
||||||
|
`insert into quiz_sessions (couple_id, question_ids)
|
||||||
|
values ($1, $2::uuid[])
|
||||||
|
on conflict (couple_id) where status = 'active' do nothing
|
||||||
|
returning *`,
|
||||||
|
[coupleId, questionIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sessionRow = inserted.rows[0];
|
||||||
|
let created = true;
|
||||||
|
if (!sessionRow) {
|
||||||
|
created = false;
|
||||||
|
const raced = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
sessionRow = raced.rows[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session: await this.buildPayload(client, sessionRow, userId), created };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!coupleId) return null;
|
||||||
|
const result = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return this.buildPayload(this.pool, row, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const sessionResult = await client.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1 for update',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'active') {
|
||||||
|
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionIds = new Set(session.question_ids);
|
||||||
|
const answeredIds = new Set(answers.map((answer) => answer.question_id));
|
||||||
|
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
for (const id of answeredIds) {
|
||||||
|
if (!questionIds.has(id)) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
|
||||||
|
for (const answer of answers) {
|
||||||
|
const options = optionsByQuestion.get(answer.question_id);
|
||||||
|
if (!options) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
answer.self_index < 0 || answer.self_index >= options.length ||
|
||||||
|
answer.guess_index < 0 || answer.guess_index >= options.length
|
||||||
|
) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const answer of answers) {
|
||||||
|
await client.query(
|
||||||
|
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
on conflict (session_id, user_id, question_id)
|
||||||
|
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
|
||||||
|
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
let updatedSession = session;
|
||||||
|
if (partnerId) {
|
||||||
|
const counts = await client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
const bothComplete =
|
||||||
|
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
|
||||||
|
|
||||||
|
if (bothComplete) {
|
||||||
|
const allAnswers = await client.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId)!;
|
||||||
|
const theirs = byUser.get(partnerId)!;
|
||||||
|
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
|
||||||
|
questionId,
|
||||||
|
aSelfIndex: mine.get(questionId)!.self_index,
|
||||||
|
aGuessIndex: mine.get(questionId)!.guess_index,
|
||||||
|
bSelfIndex: theirs.get(questionId)!.self_index,
|
||||||
|
bGuessIndex: theirs.get(questionId)!.guess_index,
|
||||||
|
}));
|
||||||
|
const { scorePercent } = computeQuizScore(pairs);
|
||||||
|
|
||||||
|
const completed = await client.query<QuizSessionRow>(
|
||||||
|
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning *`,
|
||||||
|
[sessionId, scorePercent],
|
||||||
|
);
|
||||||
|
updatedSession = completed.rows[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildPayload(client, updatedSession, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
|
||||||
|
const sessionResult = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'completed') {
|
||||||
|
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
|
||||||
|
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
|
||||||
|
const [questions, allAnswers] = await Promise.all([
|
||||||
|
this.questionsFor(this.pool, session.question_ids),
|
||||||
|
this.pool.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
|
||||||
|
const questionsPayload = questions.map((question) => {
|
||||||
|
const my = mine.get(question.id);
|
||||||
|
const partner = theirs.get(question.id);
|
||||||
|
const mySelf = my?.self_index ?? 0;
|
||||||
|
const myGuess = my?.guess_index ?? 0;
|
||||||
|
const partnerSelf = partner?.self_index ?? 0;
|
||||||
|
const partnerGuess = partner?.guess_index ?? 0;
|
||||||
|
return {
|
||||||
|
question_id: question.id,
|
||||||
|
prompt_self: question.prompt_self,
|
||||||
|
prompt_guess: question.prompt_guess,
|
||||||
|
options: question.options,
|
||||||
|
my_self_index: mySelf,
|
||||||
|
my_guess_index: myGuess,
|
||||||
|
partner_self_index: partnerSelf,
|
||||||
|
partner_guess_index: partnerGuess,
|
||||||
|
i_guessed_right: myGuess === partnerSelf,
|
||||||
|
partner_guessed_right: partnerGuess === mySelf,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
score_percent: session.score_percent ?? 0,
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
questions: questionsPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ApiErrorResponse } from '@sauci/shared';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
|
import { QuizError, type QuizAnswerInput } from './types.js';
|
||||||
|
import type { QuizRepository } from './repository.js';
|
||||||
|
|
||||||
|
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
|
|
||||||
|
const id = z.string().uuid();
|
||||||
|
|
||||||
|
const answerEntry = z
|
||||||
|
.object({
|
||||||
|
question_id: id,
|
||||||
|
self_index: z.number().int().min(0),
|
||||||
|
guess_index: z.number().int().min(0),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function result<T>(operation: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return { ok: true as const, value: await operation() };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof QuizError) return { ok: false as const, cause };
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
|
||||||
|
app.post('/v1/quiz/sessions', async (c) => {
|
||||||
|
const response = await result(() => repository.startSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/current', async (c) => {
|
||||||
|
const response = await result(() => repository.currentSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
|
||||||
|
const answers: QuizAnswerInput[] = body.data.answers;
|
||||||
|
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json(response.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { couples } from '../couples/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
|
||||||
|
* same question so a session can ask each partner about themselves and about the
|
||||||
|
* other without a second row.
|
||||||
|
*/
|
||||||
|
export const quizQuestions = pgTable('quiz_questions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
promptSelf: text('prompt_self').notNull().unique(),
|
||||||
|
promptGuess: text('prompt_guess').notNull(),
|
||||||
|
options: text('options').array().notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizSessions = pgTable('quiz_sessions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').notNull().default('active'),
|
||||||
|
questionIds: uuid('question_ids').array().notNull(),
|
||||||
|
scorePercent: integer('score_percent'),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
|
||||||
|
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
|
||||||
|
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizAnswers = pgTable('quiz_answers', {
|
||||||
|
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
questionId: uuid('question_id').notNull(),
|
||||||
|
selfIndex: integer('self_index').notNull(),
|
||||||
|
guessIndex: integer('guess_index').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
|
||||||
|
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
|
||||||
|
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
|
||||||
|
export type QuizSessionRow = typeof quizSessions.$inferSelect;
|
||||||
|
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* One partner's guess of the other's self-answer, for a single question. Scoring
|
||||||
|
* always runs both directions: A guessing B and B guessing A each count separately,
|
||||||
|
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
|
||||||
|
*/
|
||||||
|
export interface QuizAnswerPair {
|
||||||
|
questionId: string;
|
||||||
|
aSelfIndex: number;
|
||||||
|
aGuessIndex: number;
|
||||||
|
bSelfIndex: number;
|
||||||
|
bGuessIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizScoreQuestionResult extends QuizAnswerPair {
|
||||||
|
aGuessedRight: boolean;
|
||||||
|
bGuessedRight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizScoreResult {
|
||||||
|
scorePercent: number;
|
||||||
|
perQuestion: QuizScoreQuestionResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure scoring function for a completed quiz session. Each question contributes
|
||||||
|
* up to two hits (partner A's guess about B, and B's guess about A), so the
|
||||||
|
* denominator is twice the question count.
|
||||||
|
*/
|
||||||
|
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
|
||||||
|
const perQuestion = pairs.map((pair) => ({
|
||||||
|
...pair,
|
||||||
|
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
|
||||||
|
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
|
||||||
|
}));
|
||||||
|
const hits = perQuestion.reduce(
|
||||||
|
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
|
||||||
|
return { scorePercent, perQuestion };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type QuizSessionStatus = 'active' | 'completed';
|
||||||
|
|
||||||
|
export type QuizErrorCode =
|
||||||
|
| 'no_couple'
|
||||||
|
| 'partner_required'
|
||||||
|
| 'session_not_found'
|
||||||
|
| 'session_completed'
|
||||||
|
| 'invalid_answers'
|
||||||
|
| 'session_not_completed';
|
||||||
|
|
||||||
|
export class QuizError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: QuizErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly status: 400 | 404 | 409,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QuizError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizAnswerInput {
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestionSummary {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizSessionPayload {
|
||||||
|
id: string;
|
||||||
|
status: QuizSessionStatus;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
score_percent: number | null;
|
||||||
|
questions: QuizQuestionSummary[];
|
||||||
|
my_answers: QuizAnswerInput[];
|
||||||
|
partner_completed: boolean;
|
||||||
|
i_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResultQuestion {
|
||||||
|
question_id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
my_self_index: number;
|
||||||
|
my_guess_index: number;
|
||||||
|
partner_self_index: number;
|
||||||
|
partner_guess_index: number;
|
||||||
|
i_guessed_right: boolean;
|
||||||
|
partner_guessed_right: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResultPayload {
|
||||||
|
score_percent: number;
|
||||||
|
completed_at: string | null;
|
||||||
|
questions: QuizResultQuestion[];
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||||
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||||
|
import { PostgresQuizRepository } from './domains/quiz/repository.js';
|
||||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||||
import { CoupleService } from './domains/couples/service.js';
|
import { CoupleService } from './domains/couples/service.js';
|
||||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
|
|||||||
const answersRepository = new PostgresAnswersRepository(pool);
|
const answersRepository = new PostgresAnswersRepository(pool);
|
||||||
const chatRepository = new PostgresChatRepository(pool);
|
const chatRepository = new PostgresChatRepository(pool);
|
||||||
const daresRepository = new PostgresDaresRepository(pool);
|
const daresRepository = new PostgresDaresRepository(pool);
|
||||||
|
const quizRepository = new PostgresQuizRepository(pool);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||||
const accountOperationsService = new AccountOperationsService(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
@@ -68,6 +70,7 @@ const app = createApp({
|
|||||||
answersRepository,
|
answersRepository,
|
||||||
chatRepository,
|
chatRepository,
|
||||||
daresRepository,
|
daresRepository,
|
||||||
|
quizRepository,
|
||||||
profileSettingsRepository,
|
profileSettingsRepository,
|
||||||
accountOperationsService,
|
accountOperationsService,
|
||||||
mediaRepository,
|
mediaRepository,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { PostgresQuizRepository } from '../src/domains/quiz/repository.js';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
|
||||||
|
if (url && !local) throw new Error('Quiz integration tests only permit localhost');
|
||||||
|
|
||||||
|
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
|
||||||
|
const admin = new Pool({ connectionString: url });
|
||||||
|
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
let pool: Pool;
|
||||||
|
let repo: PostgresQuizRepository;
|
||||||
|
|
||||||
|
const userA = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const userB = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const soloUser = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const outsider = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const couple = '55555555-5555-4555-8555-555555555555';
|
||||||
|
const soloCouple = '66666666-6666-4666-8666-666666666666';
|
||||||
|
const otherCouple = '77777777-7777-4777-8777-777777777777';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await admin.query(`create schema "${schema}"`);
|
||||||
|
const isolated = new URL(url!);
|
||||||
|
isolated.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
|
pool = new Pool({ connectionString: isolated.toString() });
|
||||||
|
repo = new PostgresQuizRepository(isolated.toString());
|
||||||
|
|
||||||
|
const dir = new URL('../drizzle/', import.meta.url);
|
||||||
|
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||||
|
for (const name of files) {
|
||||||
|
const sql = await readFile(new URL(name, dir), 'utf8');
|
||||||
|
for (const statement of sql.split('--> statement-breakpoint')) {
|
||||||
|
if (statement.trim()) await pool.query(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'insert into couples(id,invite_code) values($1,$2),($3,$4),($5,$6)',
|
||||||
|
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into profiles(id,couple_id) values
|
||||||
|
($1,$5),($2,$5),($3,$6),($4,$7)`,
|
||||||
|
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await pool.query('delete from quiz_answers');
|
||||||
|
await pool.query('delete from quiz_sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await repo.close();
|
||||||
|
await pool.end();
|
||||||
|
await admin.query(`drop schema "${schema}" cascade`);
|
||||||
|
await admin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start a quiz without a couple or without a partner', async () => {
|
||||||
|
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
|
||||||
|
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a session with ten active questions and returns it idempotently', async () => {
|
||||||
|
const first = await repo.startSession(userA);
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(first.session.status).toBe('active');
|
||||||
|
expect(first.session.questions).toHaveLength(10);
|
||||||
|
expect(first.session.score_percent).toBeNull();
|
||||||
|
expect(first.session.my_answers).toEqual([]);
|
||||||
|
expect(first.session.i_completed).toBe(false);
|
||||||
|
expect(first.session.partner_completed).toBe(false);
|
||||||
|
|
||||||
|
const second = await repo.startSession(userA);
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.session.id).toBe(first.session.id);
|
||||||
|
|
||||||
|
// The partner starting also resolves to the same active session.
|
||||||
|
const partnerView = await repo.startSession(userB);
|
||||||
|
expect(partnerView.created).toBe(false);
|
||||||
|
expect(partnerView.session.id).toBe(first.session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
expect(questionIds).toHaveLength(10);
|
||||||
|
|
||||||
|
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
|
||||||
|
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
|
||||||
|
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
|
||||||
|
// questions and wrong on the rest.
|
||||||
|
const bSelf = questionIds.map((_, index) => index % 2);
|
||||||
|
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
|
||||||
|
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
|
||||||
|
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
|
||||||
|
|
||||||
|
const aAnswers = questionIds.map((questionId, index) => ({
|
||||||
|
question_id: questionId,
|
||||||
|
self_index: aSelf[index]!,
|
||||||
|
guess_index: aGuess[index]!,
|
||||||
|
}));
|
||||||
|
const bAnswers = questionIds.map((questionId, index) => ({
|
||||||
|
question_id: questionId,
|
||||||
|
self_index: bSelf[index]!,
|
||||||
|
guess_index: bGuess[index]!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
|
||||||
|
expect(afterA.status).toBe('active');
|
||||||
|
expect(afterA.i_completed).toBe(true);
|
||||||
|
expect(afterA.partner_completed).toBe(false);
|
||||||
|
expect(afterA.my_answers).toHaveLength(10);
|
||||||
|
|
||||||
|
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
|
||||||
|
expect(afterB.status).toBe('completed');
|
||||||
|
expect(afterB.completed_at).not.toBeNull();
|
||||||
|
expect(afterB.i_completed).toBe(true);
|
||||||
|
expect(afterB.partner_completed).toBe(true);
|
||||||
|
|
||||||
|
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
|
||||||
|
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
|
||||||
|
// Total 9 hits out of 20 possible = 45%.
|
||||||
|
expect(afterB.score_percent).toBe(45);
|
||||||
|
|
||||||
|
const result = await repo.result(userA, session.id);
|
||||||
|
expect(result.score_percent).toBe(45);
|
||||||
|
expect(result.completed_at).not.toBeNull();
|
||||||
|
expect(result.questions).toHaveLength(10);
|
||||||
|
expect(result.questions[0]).toMatchObject({
|
||||||
|
my_self_index: aSelf[0],
|
||||||
|
my_guess_index: aGuess[0],
|
||||||
|
partner_self_index: bSelf[0],
|
||||||
|
partner_guess_index: bGuess[0],
|
||||||
|
i_guessed_right: true,
|
||||||
|
partner_guessed_right: true,
|
||||||
|
});
|
||||||
|
expect(result.questions[9]).toMatchObject({
|
||||||
|
i_guessed_right: false,
|
||||||
|
partner_guessed_right: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The partner's result view mirrors the same score with roles swapped.
|
||||||
|
const partnerResult = await repo.result(userB, session.id);
|
||||||
|
expect(partnerResult.score_percent).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an incomplete or mismatched answer set', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, [
|
||||||
|
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
|
||||||
|
]),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, [
|
||||||
|
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
|
||||||
|
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
|
||||||
|
]),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
|
||||||
|
const options = session.questions[0]!.options;
|
||||||
|
await expect(
|
||||||
|
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
|
||||||
|
question_id: id,
|
||||||
|
self_index: options.length,
|
||||||
|
guess_index: 0,
|
||||||
|
}))),
|
||||||
|
).rejects.toMatchObject({ code: 'invalid_answers' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates sessions to their own couple', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
|
||||||
|
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
|
||||||
|
expect(await repo.currentSession(outsider)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the answers endpoint once the session has completed', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const questionIds = session.questions.map((question) => question.id);
|
||||||
|
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||||
|
await repo.submitAnswers(userA, session.id, answers);
|
||||||
|
const completed = await repo.submitAnswers(userB, session.id, answers);
|
||||||
|
expect(completed.status).toBe('completed');
|
||||||
|
|
||||||
|
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the result endpoint before the session has completed', async () => {
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the couple\'s most recent session from currentSession', async () => {
|
||||||
|
expect(await repo.currentSession(userA)).toBeNull();
|
||||||
|
const { session } = await repo.startSession(userA);
|
||||||
|
const current = await repo.currentSession(userB);
|
||||||
|
expect(current?.id).toBe(session.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthIdentity } from '../src/auth.js';
|
||||||
|
import type { QuizRepository } from '../src/domains/quiz/repository.js';
|
||||||
|
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
|
||||||
|
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
|
||||||
|
|
||||||
|
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||||
|
const sessionId = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const questionId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
|
||||||
|
const session: QuizSessionPayload = {
|
||||||
|
id: sessionId,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-08-27T00:00:00.000Z',
|
||||||
|
completed_at: null,
|
||||||
|
score_percent: null,
|
||||||
|
questions: [
|
||||||
|
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
|
||||||
|
],
|
||||||
|
my_answers: [],
|
||||||
|
partner_completed: false,
|
||||||
|
i_completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(overrides: Partial<QuizRepository> = {}) {
|
||||||
|
const repository: QuizRepository = {
|
||||||
|
startSession: vi.fn(async () => ({ session, created: true })),
|
||||||
|
currentSession: vi.fn(async () => session),
|
||||||
|
submitAnswers: vi.fn(async () => session),
|
||||||
|
result: vi.fn(async () => ({
|
||||||
|
score_percent: 100,
|
||||||
|
completed_at: '2026-08-27T00:00:00.000Z',
|
||||||
|
questions: [],
|
||||||
|
})),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||||
|
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||||
|
registerQuizRoutes(app, repository);
|
||||||
|
return { app, repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||||
|
return app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('quiz routes', () => {
|
||||||
|
it('starts a session and returns 201 when a new one is created', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await response.json()).toEqual({ session });
|
||||||
|
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 when starting resolves to an existing active session', async () => {
|
||||||
|
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps no_couple and partner_required to 409', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
|
||||||
|
});
|
||||||
|
const response = await post(app, '/v1/quiz/sessions');
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current session, including null when none exists', async () => {
|
||||||
|
const { app } = setup({ currentSession: vi.fn(async () => null) });
|
||||||
|
const response = await app.request('/v1/quiz/sessions/current');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ session: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed session id on the answers route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty or malformed answer body', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
|
||||||
|
})).status).toBe(400);
|
||||||
|
expect(repository.submitAnswers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits valid answers and surfaces the updated session', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
|
||||||
|
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps session errors to their status codes', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
|
||||||
|
});
|
||||||
|
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
|
||||||
|
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the result payload for a completed session', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ score_percent: 100 });
|
||||||
|
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps session_not_completed to 409 on the result route', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
|
||||||
|
});
|
||||||
|
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed session id on the result route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(repository.result).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
|
||||||
|
|
||||||
|
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
|
||||||
|
return {
|
||||||
|
questionId: 'q1',
|
||||||
|
aSelfIndex: 0,
|
||||||
|
aGuessIndex: 0,
|
||||||
|
bSelfIndex: 0,
|
||||||
|
bGuessIndex: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeQuizScore', () => {
|
||||||
|
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
|
||||||
|
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
|
||||||
|
];
|
||||||
|
const { scorePercent } = computeQuizScore(pairs);
|
||||||
|
expect(scorePercent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores 0% when nobody guesses their partner correctly', () => {
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
|
||||||
|
];
|
||||||
|
expect(computeQuizScore(pairs).scorePercent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts each direction independently, so a half-right couple lands at 50%', () => {
|
||||||
|
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
|
||||||
|
];
|
||||||
|
const result = computeQuizScore(pairs);
|
||||||
|
expect(result.scorePercent).toBe(50);
|
||||||
|
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds to the nearest whole percent', () => {
|
||||||
|
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
|
||||||
|
const pairs: QuizAnswerPair[] = [
|
||||||
|
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
|
||||||
|
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
|
||||||
|
];
|
||||||
|
expect(computeQuizScore(pairs).scorePercent).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0% for an empty question set instead of dividing by zero', () => {
|
||||||
|
expect(computeQuizScore([]).scorePercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 47
|
versionCode 48
|
||||||
versionName "1.0.5"
|
versionName "1.0.6"
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
|||||||
+30
-3
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
@@ -16,12 +16,18 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
|
"associatedDomains": [
|
||||||
|
"applinks:sauci.app"
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
"entitlements": {
|
"entitlements": {
|
||||||
"com.apple.security.application-groups": [
|
"com.apple.security.application-groups": [
|
||||||
"group.com.sauci.app"
|
"group.com.sauci.app"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"buildNumber": "43",
|
"buildNumber": "45",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"googleServicesFile": "./GoogleService-Info.plist",
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
@@ -183,7 +189,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 47,
|
"versionCode": 48,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -191,6 +197,23 @@
|
|||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT",
|
"android.permission.USE_FINGERPRINT",
|
||||||
"com.android.vending.BILLING"
|
"com.android.vending.BILLING"
|
||||||
|
],
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"autoVerify": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"scheme": "https",
|
||||||
|
"host": "sauci.app",
|
||||||
|
"pathPrefix": "/join"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": [
|
||||||
|
"BROWSABLE",
|
||||||
|
"DEFAULT"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
@@ -208,6 +231,10 @@
|
|||||||
"buildToolsVersion": "36.0.0",
|
"buildToolsVersion": "36.0.0",
|
||||||
"compileSdkVersion": 36,
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 36,
|
"targetSdkVersion": 36,
|
||||||
|
"blockedPermissions": [
|
||||||
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
|
"android.permission.READ_MEDIA_VIDEO"
|
||||||
|
],
|
||||||
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
"useLegacyPackaging": false,
|
"useLegacyPackaging": false,
|
||||||
"manifestPlaceholders": {
|
"manifestPlaceholders": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as Haptics from 'expo-haptics';
|
|||||||
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||||
import { GradientBackground } from '../../src/components/ui';
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
|
||||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
import { colors, spacing, typography } from '../../src/theme';
|
import { colors, spacing, typography } from '../../src/theme';
|
||||||
@@ -166,6 +166,9 @@ export default function DiscoveryScreen() {
|
|||||||
{/* Dares */}
|
{/* Dares */}
|
||||||
<DaresTile delay={200} />
|
<DaresTile delay={200} />
|
||||||
|
|
||||||
|
{/* Quiz */}
|
||||||
|
<QuizTile delay={225} />
|
||||||
|
|
||||||
{/* Content Rows by Category */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
<ContentRow
|
<ContentRow
|
||||||
|
|||||||
@@ -20,14 +20,21 @@ import { useAuthStore } from "../../src/store";
|
|||||||
import { coupleApi } from "../../src/lib/coupleApi";
|
import { coupleApi } from "../../src/lib/coupleApi";
|
||||||
import { getPairingError } from "../../src/lib/errors";
|
import { getPairingError } from "../../src/lib/errors";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
||||||
|
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||||
|
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||||
|
|
||||||
export default function PairingScreen() {
|
export default function PairingScreen() {
|
||||||
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||||
|
const params = useLocalSearchParams<{ code?: string }>();
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||||
|
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||||
|
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||||
|
|
||||||
// Redirect if already paired
|
// Redirect if already paired
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,6 +43,64 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
|
|
||||||
|
// One-time prefill: apply a code carried by a join link (route param), then
|
||||||
|
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||||
|
// match. Never auto-submits; the user still taps "Join Partner".
|
||||||
|
useEffect(() => {
|
||||||
|
if (couple) return; // only relevant to the unpaired join flow
|
||||||
|
if (prefillAttempted) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const applyPrefill = async (code: string) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setInviteCode(code);
|
||||||
|
setWasPrefilled(true);
|
||||||
|
Events.pairingCodePrefilled();
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||||
|
if (isValidInviteCode(routeCode)) {
|
||||||
|
await applyPrefill(routeCode);
|
||||||
|
await clearPendingInviteCode();
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stashedCode = await getPendingInviteCode();
|
||||||
|
if (stashedCode) {
|
||||||
|
await applyPrefill(stashedCode);
|
||||||
|
await clearPendingInviteCode();
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardCode = await checkClipboardForInviteCode();
|
||||||
|
if (clipboardCode && !cancelled) {
|
||||||
|
setClipboardOfferCode(clipboardCode);
|
||||||
|
}
|
||||||
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [couple, params.code, prefillAttempted]);
|
||||||
|
|
||||||
|
const acceptClipboardOffer = () => {
|
||||||
|
if (!clipboardOfferCode) return;
|
||||||
|
setInviteCode(clipboardOfferCode);
|
||||||
|
setWasPrefilled(true);
|
||||||
|
Events.inviteLinkOpened("clipboard");
|
||||||
|
Events.pairingCodePrefilled();
|
||||||
|
setClipboardOfferCode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissClipboardOffer = () => {
|
||||||
|
setClipboardOfferCode(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
||||||
// be added later without coupling product data back to Supabase.
|
// be added later without coupling product data back to Supabase.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,8 +165,13 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInviteCodeChange = (value: string) => {
|
||||||
|
setWasPrefilled(false);
|
||||||
|
setInviteCode(value);
|
||||||
|
};
|
||||||
|
|
||||||
const shareMessage = couple?.invite_code
|
const shareMessage = couple?.invite_code
|
||||||
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
|
? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const shareCode = async () => {
|
const shareCode = async () => {
|
||||||
@@ -338,6 +408,37 @@ export default function PairingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Clipboard invite code offer */}
|
||||||
|
{clipboardOfferCode && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.duration(400)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<View style={styles.clipboardOffer} testID="clipboard-invite-offer">
|
||||||
|
<Ionicons name="clipboard-outline" size={18} color={colors.primary} />
|
||||||
|
<Text style={styles.clipboardOfferText}>
|
||||||
|
Use code {clipboardOfferCode} from your clipboard?
|
||||||
|
</Text>
|
||||||
|
<View style={styles.clipboardOfferActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={dismissClipboardOffer}
|
||||||
|
style={styles.clipboardOfferButton}
|
||||||
|
testID="clipboard-invite-offer-dismiss"
|
||||||
|
>
|
||||||
|
<Text style={styles.clipboardOfferDismissText}>Not now</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={acceptClipboardOffer}
|
||||||
|
style={[styles.clipboardOfferButton, styles.clipboardOfferButtonPrimary]}
|
||||||
|
testID="clipboard-invite-offer-accept"
|
||||||
|
>
|
||||||
|
<Text style={styles.clipboardOfferAcceptText}>Use code</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Join Card */}
|
{/* Join Card */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
@@ -346,14 +447,20 @@ export default function PairingScreen() {
|
|||||||
<Text style={styles.sectionTitle}>Have a code?</Text>
|
<Text style={styles.sectionTitle}>Have a code?</Text>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, wasPrefilled && styles.inputPrefilled]}
|
||||||
placeholder="Enter invite code"
|
placeholder="Enter invite code"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChangeText={setInviteCode}
|
onChangeText={handleInviteCodeChange}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
|
testID="invite-code-input"
|
||||||
/>
|
/>
|
||||||
|
{wasPrefilled && (
|
||||||
|
<Text style={styles.prefilledCaption}>
|
||||||
|
Code applied from your invite link
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleJoinCouple}
|
onPress={handleJoinCouple}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -436,6 +543,50 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
inputPrefilled: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
prefilledCaption: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.primary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: -spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
clipboardOffer: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
clipboardOfferActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferButton: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
clipboardOfferButtonPrimary: {
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
},
|
||||||
|
clipboardOfferDismissText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
clipboardOfferAcceptText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
// ...
|
// ...
|
||||||
dividerLine: {
|
dividerLine: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,232 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
import { QuizScreen } from "../../src/features/quiz";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
|
||||||
import { GradientBackground } from "../../src/components/ui";
|
|
||||||
import { colors, spacing, radius, typography, animations } from "../../src/theme";
|
|
||||||
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
|
|
||||||
|
|
||||||
const ACCENT_COLOR = colors.premium.rose;
|
export default function QuizRoute() {
|
||||||
|
return <QuizScreen />;
|
||||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
||||||
|
|
||||||
export default function QuizScreen() {
|
|
||||||
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
|
|
||||||
useFeatureInterest("quiz");
|
|
||||||
|
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: scale.value }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handlePressIn = () => {
|
|
||||||
scale.value = withSpring(0.96, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePressOut = () => {
|
|
||||||
scale.value = withSpring(1, animations.spring);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GradientBackground>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.duration(600).springify()}
|
|
||||||
style={styles.content}
|
|
||||||
>
|
|
||||||
{/* Title section */}
|
|
||||||
<Text style={styles.label}>CONNECTION</Text>
|
|
||||||
<Text style={styles.title}>Quiz</Text>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
|
||||||
<View style={styles.separator}>
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
<View style={styles.separatorDiamond} />
|
|
||||||
<View style={styles.separatorLine} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Coming soon badge */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.delay(300).duration(400)}
|
|
||||||
style={styles.badge}
|
|
||||||
>
|
|
||||||
<Text style={styles.badgeText}>ARRIVING SOON</Text>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Text style={styles.description}>
|
|
||||||
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Feature hints */}
|
|
||||||
<View style={styles.features}>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Learn what makes each other tick</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Deepen your bond</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.featureText}>Track your journey</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom teaser */}
|
|
||||||
<Text style={styles.teaser}>Something special is being crafted for you</Text>
|
|
||||||
|
|
||||||
{/* Interest button - always reserve space to prevent layout shift */}
|
|
||||||
<View style={styles.interestButtonContainer}>
|
|
||||||
{isAuthenticated && !isLoading && (
|
|
||||||
<Animated.View entering={FadeIn.delay(500).duration(400)}>
|
|
||||||
<AnimatedPressable
|
|
||||||
onPress={toggleInterest}
|
|
||||||
onPressIn={handlePressIn}
|
|
||||||
onPressOut={handlePressOut}
|
|
||||||
disabled={isToggling}
|
|
||||||
style={[
|
|
||||||
styles.interestButton,
|
|
||||||
isInterested && styles.interestButtonActive,
|
|
||||||
animatedButtonStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isToggling ? (
|
|
||||||
<ActivityIndicator size="small" color={ACCENT_COLOR} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons
|
|
||||||
name={isInterested ? "checkmark-circle" : "notifications-outline"}
|
|
||||||
size={16}
|
|
||||||
color={ACCENT_COLOR}
|
|
||||||
/>
|
|
||||||
<Text style={styles.interestButtonText}>
|
|
||||||
{isInterested ? "You're on the list!" : "I'm interested"}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatedPressable>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</GradientBackground>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
...typography.caption1,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 3,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
...typography.largeTitle,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginVertical: spacing.lg,
|
|
||||||
width: 140,
|
|
||||||
},
|
|
||||||
separatorLine: {
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(232, 164, 174, 0.3)',
|
|
||||||
},
|
|
||||||
separatorDiamond: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
transform: [{ rotate: '45deg' }],
|
|
||||||
marginHorizontal: spacing.md,
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
badge: {
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
interestButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm + 2,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.border,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
interestButtonActive: {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
},
|
|
||||||
interestButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
badgeText: {
|
|
||||||
...typography.caption2,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 2,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
...typography.body,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
featureItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
featureText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
teaser: {
|
|
||||||
...typography.footnote,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
color: colors.textTertiary,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
interestButtonContainer: {
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
minHeight: 44,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -46,6 +46,9 @@ import { useAuthStore } from "../src/store";
|
|||||||
import { authClient } from "../src/lib/authClient";
|
import { authClient } from "../src/lib/authClient";
|
||||||
import { colors, spacing, radius, typography } from "../src/theme";
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||||
|
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
||||||
|
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -117,6 +120,22 @@ export default function RootLayout() {
|
|||||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
|
||||||
|
// are handled separately from the Supabase auth deep link flow below.
|
||||||
|
const inviteLink = parseInviteLinkCode(url);
|
||||||
|
if (inviteLink) {
|
||||||
|
console.log('[DeepLink] Invite link detected:', inviteLink);
|
||||||
|
Events.inviteLinkOpened(inviteLink.source);
|
||||||
|
await stashPendingInviteCode(inviteLink.code);
|
||||||
|
|
||||||
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
|
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||||
|
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
||||||
|
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse the URL to extract auth parameters
|
// Parse the URL to extract auth parameters
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
import { needsOnboarding } from "../src/constants/onboarding";
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
|
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
|
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
||||||
|
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
||||||
|
|
||||||
|
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||||
|
// applied (e.g. the user tapped a join link before signing in).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !isAuthenticated || user?.couple_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
getPendingInviteCode().then((code) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setHasPendingInvite(!!code);
|
||||||
|
setPendingCodeChecked(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isLoading, isAuthenticated, user?.couple_id]);
|
||||||
|
|
||||||
// Show loading state while auth is being determined
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -25,6 +48,26 @@ export default function Index() {
|
|||||||
return <Redirect href="/(app)/onboarding" />;
|
return <Redirect href="/(app)/onboarding" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A couple already exists for this user; no invite hand-off is relevant.
|
||||||
|
if (user?.couple_id) {
|
||||||
|
return <Redirect href="/(app)" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the one-time pending invite code check before deciding the
|
||||||
|
// route, so a stashed code from a join link is applied without an extra
|
||||||
|
// trip through the home screen.
|
||||||
|
if (!pendingCodeChecked) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingInvite) {
|
||||||
|
return <Redirect href="/(app)/pairing" />;
|
||||||
|
}
|
||||||
|
|
||||||
// Go to home/dashboard - users can pair from within the app
|
// Go to home/dashboard - users can pair from within the app
|
||||||
// No forced redirect to pairing for users without a couple
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)" />;
|
return <Redirect href="/(app)" />;
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ module.exports = function (api) {
|
|||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-worklets/plugin'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
+11
-1
@@ -32,6 +32,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {}
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "draft",
|
||||||
|
"changesNotSentForReview": true
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"ascAppId": "6757159885",
|
||||||
|
"appleTeamId": "BXVPBATDZY"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2442
-1286
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"expo.jsEngine": "hermes",
|
"expo.jsEngine": "hermes",
|
||||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||||
"newArchEnabled": "false",
|
"newArchEnabled": "true",
|
||||||
"ios.useFrameworks": "static",
|
"ios.useFrameworks": "static",
|
||||||
"apple.extraPods": "[]",
|
"apple.extraPods": "[]",
|
||||||
"apple.ccacheEnabled": "false",
|
"apple.ccacheEnabled": "false",
|
||||||
|
|||||||
@@ -7,15 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
|
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
|
||||||
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
|
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
|
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
|
||||||
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
|
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
|
||||||
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
|
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
|
||||||
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
|
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
|
||||||
@@ -50,11 +49,8 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
|
||||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
|
||||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
@@ -64,6 +60,7 @@
|
|||||||
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
@@ -126,11 +123,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
|
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
13B07FB71A68108700A75B9A /* main.m */,
|
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
|
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
|
||||||
F744EAD4ABA54509966475D6 /* noop-file.swift */,
|
F744EAD4ABA54509966475D6 /* noop-file.swift */,
|
||||||
@@ -421,6 +416,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
||||||
@@ -455,6 +451,7 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
|
||||||
@@ -497,11 +494,16 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/.xcode.env",
|
||||||
|
"$(SRCROOT)/.xcode.env.local",
|
||||||
|
"$(SRCROOT)/Sauci/Sauci.entitlements",
|
||||||
|
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/expo-configure-project.sh",
|
||||||
);
|
);
|
||||||
name = "[Expo] Configure project";
|
name = "[Expo] Configure project";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
|
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
@@ -527,8 +529,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||||
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
|
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -569,7 +570,7 @@
|
|||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.6;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -598,7 +599,7 @@
|
|||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.6;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -671,6 +672,8 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
|
||||||
);
|
);
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
@@ -738,6 +741,8 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
|
||||||
);
|
);
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
@@ -767,7 +772,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 44;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -777,7 +782,7 @@
|
|||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.6;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
||||||
@@ -803,7 +808,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 44;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@@ -813,7 +818,7 @@
|
|||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
MARKETING_VERSION = 1.0.5;
|
MARKETING_VERSION = 1.0.6;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#import <RCTAppDelegate.h>
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
#import <Expo/Expo.h>
|
|
||||||
|
|
||||||
@interface AppDelegate : EXAppDelegateWrapper
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#import "AppDelegate.h"
|
|
||||||
#import <Firebase/Firebase.h>
|
|
||||||
|
|
||||||
#import <React/RCTBundleURLProvider.h>
|
|
||||||
#import <React/RCTLinkingManager.h>
|
|
||||||
|
|
||||||
@implementation AppDelegate
|
|
||||||
|
|
||||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
|
||||||
{
|
|
||||||
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-ecd111c37e49fdd1ed6354203cd6b1e2a38cccda
|
|
||||||
[FIRApp configure];
|
|
||||||
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
|
|
||||||
self.moduleName = @"main";
|
|
||||||
|
|
||||||
// You can add your custom initial props in the dictionary below.
|
|
||||||
// They will be passed down to the ViewController used by React Native.
|
|
||||||
self.initialProps = @{};
|
|
||||||
|
|
||||||
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
|
|
||||||
{
|
|
||||||
return [self bundleURL];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSURL *)bundleURL
|
|
||||||
{
|
|
||||||
#if DEBUG
|
|
||||||
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
|
|
||||||
#else
|
|
||||||
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linking API
|
|
||||||
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
|
|
||||||
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Universal Links
|
|
||||||
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
|
|
||||||
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
|
|
||||||
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
|
|
||||||
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
|
|
||||||
{
|
|
||||||
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
|
|
||||||
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
|
|
||||||
{
|
|
||||||
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
|
|
||||||
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
|
|
||||||
{
|
|
||||||
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Expo
|
||||||
|
import FirebaseCore
|
||||||
|
import React
|
||||||
|
import ReactAppDependencyProvider
|
||||||
|
|
||||||
|
@UIApplicationMain
|
||||||
|
public class AppDelegate: ExpoAppDelegate {
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
|
||||||
|
var reactNativeFactory: RCTReactNativeFactory?
|
||||||
|
|
||||||
|
public override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
let delegate = ReactNativeDelegate()
|
||||||
|
let factory = ExpoReactNativeFactory(delegate: delegate)
|
||||||
|
delegate.dependencyProvider = RCTAppDependencyProvider()
|
||||||
|
|
||||||
|
reactNativeDelegate = delegate
|
||||||
|
reactNativeFactory = factory
|
||||||
|
bindReactNativeFactory(factory)
|
||||||
|
|
||||||
|
#if os(iOS) || os(tvOS)
|
||||||
|
window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
FirebaseApp.configure()
|
||||||
|
factory.startReactNative(
|
||||||
|
withModuleName: "main",
|
||||||
|
in: window,
|
||||||
|
launchOptions: launchOptions)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func application(
|
||||||
|
_ app: UIApplication,
|
||||||
|
open url: URL,
|
||||||
|
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||||
|
) -> Bool {
|
||||||
|
super.application(app, open: url, options: options)
|
||||||
|
|| RCTLinkingManager.application(app, open: url, options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
continue userActivity: NSUserActivity,
|
||||||
|
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||||
|
) -> Bool {
|
||||||
|
let result = RCTLinkingManager.application(
|
||||||
|
application,
|
||||||
|
continue: userActivity,
|
||||||
|
restorationHandler: restorationHandler)
|
||||||
|
return super.application(
|
||||||
|
application,
|
||||||
|
continue: userActivity,
|
||||||
|
restorationHandler: restorationHandler) || result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
|
||||||
|
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
||||||
|
bridge.bundleURL ?? bundleURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bundleURL() -> URL? {
|
||||||
|
#if DEBUG
|
||||||
|
RCTBundleURLProvider.sharedSettings()
|
||||||
|
.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
|
||||||
|
#else
|
||||||
|
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-105
@@ -1,109 +1,113 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleAllowMixedLocalizations</key>
|
<key>CFBundleAllowMixedLocalizations</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Sauci</string>
|
<string>Sauci</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.5</string>
|
<string>1.0.6</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>app.sauci</string>
|
<string>app.sauci</string>
|
||||||
<string>com.sauci.app</string>
|
<string>com.sauci.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>exp+sauci</string>
|
<string>exp+sauci</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
|
<string>com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>43</string>
|
<string>45</string>
|
||||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<string>12.0</string>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<true/>
|
<string>12.0</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<dict>
|
<true/>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<false/>
|
<dict>
|
||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<false/>
|
||||||
</dict>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<key>NSCameraUsageDescription</key>
|
<true/>
|
||||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
</dict>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
|
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
<string>Sauci uses your microphone to record audio when you capture videos to share privately with your partner.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<array>
|
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
<key>NSUserActivityTypes</key>
|
||||||
</array>
|
<array>
|
||||||
<key>UIBackgroundModes</key>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||||
<array>
|
</array>
|
||||||
<string>remote-notification</string>
|
<key>RCTNewArchEnabled</key>
|
||||||
</array>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UIBackgroundModes</key>
|
||||||
<string>SplashScreen</string>
|
<array>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<string>remote-notification</string>
|
||||||
<array>
|
</array>
|
||||||
<string>arm64</string>
|
<key>UILaunchStoryboardName</key>
|
||||||
</array>
|
<string>SplashScreen</string>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<false/>
|
<array>
|
||||||
<key>UIStatusBarStyle</key>
|
<string>arm64</string>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<array>
|
<false/>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UIStatusBarStyle</key>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
</array>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<array>
|
||||||
<array>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
</array>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<array>
|
||||||
</array>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>Automatic</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<false/>
|
</array>
|
||||||
</dict>
|
<key>UIUserInterfaceStyle</key>
|
||||||
|
<string>Automatic</string>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#import <UIKit/UIKit.h>
|
|
||||||
|
|
||||||
#import "AppDelegate.h"
|
|
||||||
|
|
||||||
int main(int argc, char * argv[]) {
|
|
||||||
@autoreleasepool {
|
|
||||||
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { ActionSheetIOS, Alert, Platform } from 'react-native';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
|
||||||
|
|
||||||
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
|
getInfoAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-image-picker', () => ({
|
||||||
|
requestCameraPermissionsAsync: jest.fn(),
|
||||||
|
requestMediaLibraryPermissionsAsync: jest.fn(),
|
||||||
|
launchCameraAsync: jest.fn(),
|
||||||
|
launchImageLibraryAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAvatarPicker', () => {
|
||||||
|
const originalPlatform = Platform.OS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
|
||||||
|
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
|
||||||
|
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Android system picker without requesting broad media access', async () => {
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const chooseFromLibrary = (Alert.alert as jest.Mock).mock.calls[0][2].find(
|
||||||
|
(option: { text: string }) => option.text === 'Choose from Library'
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => chooseFromLibrary.onPress());
|
||||||
|
|
||||||
|
expect(ImagePicker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled();
|
||||||
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
exif: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the iOS library permission request before opening the picker', async () => {
|
||||||
|
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' });
|
||||||
|
(ImagePicker.requestMediaLibraryPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||||
|
const { result } = renderHook(() => useAvatarPicker());
|
||||||
|
|
||||||
|
act(() => result.current.showPicker());
|
||||||
|
const onSelect = (ActionSheetIOS.showActionSheetWithOptions as jest.Mock).mock.calls[0][1];
|
||||||
|
|
||||||
|
await act(async () => onSelect(2));
|
||||||
|
|
||||||
|
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
import { renderHook, act } from '@testing-library/react-native';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
@@ -46,6 +46,7 @@ describe('useMediaSaver', () => {
|
|||||||
|
|
||||||
expect(result.current.saving).toBe(false);
|
expect(result.current.saving).toBe(false);
|
||||||
expect(typeof result.current.saveMedia).toBe('function');
|
expect(typeof result.current.saveMedia).toBe('function');
|
||||||
|
expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves local image successfully', async () => {
|
it('saves local image successfully', async () => {
|
||||||
@@ -180,8 +181,6 @@ describe('useMediaSaver', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sets saving to true during operation', async () => {
|
it('sets saving to true during operation', async () => {
|
||||||
let savingDuringOperation = false;
|
|
||||||
|
|
||||||
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
||||||
// This would capture the saving state during the async operation
|
// This would capture the saving state during the async operation
|
||||||
// but since we can't access result.current here synchronously,
|
// but since we can't access result.current here synchronously,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import {
|
||||||
|
checkClipboardForInviteCode,
|
||||||
|
__resetClipboardInviteOfferForTests,
|
||||||
|
} from "../../lib/clipboardInviteOffer";
|
||||||
|
|
||||||
|
jest.mock("expo-clipboard", () => ({
|
||||||
|
hasStringAsync: jest.fn(),
|
||||||
|
getStringAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasStringAsync = Clipboard.hasStringAsync as jest.Mock;
|
||||||
|
const getStringAsync = Clipboard.getStringAsync as jest.Mock;
|
||||||
|
|
||||||
|
describe("checkClipboardForInviteCode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetClipboardInviteOfferForTests();
|
||||||
|
hasStringAsync.mockReset();
|
||||||
|
getStringAsync.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a normalized code when the clipboard holds a validly-shaped code", async () => {
|
||||||
|
hasStringAsync.mockResolvedValue(true);
|
||||||
|
getStringAsync.mockResolvedValue("abcd1234");
|
||||||
|
|
||||||
|
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the clipboard is empty", async () => {
|
||||||
|
hasStringAsync.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||||
|
expect(getStringAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the clipboard string does not match the invite code shape", async () => {
|
||||||
|
hasStringAsync.mockResolvedValue(true);
|
||||||
|
getStringAsync.mockResolvedValue("hello world");
|
||||||
|
|
||||||
|
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only checks the clipboard once per session", async () => {
|
||||||
|
hasStringAsync.mockResolvedValue(true);
|
||||||
|
getStringAsync.mockResolvedValue("abcd1234");
|
||||||
|
|
||||||
|
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
|
||||||
|
expect(hasStringAsync).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A second call in the same session should not re-check the clipboard.
|
||||||
|
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||||
|
expect(hasStringAsync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { isValidInviteCode, normalizeInviteCode, parseInviteLinkCode } from "../../lib/inviteLink";
|
||||||
|
|
||||||
|
describe("isValidInviteCode", () => {
|
||||||
|
it("accepts 8-character alphanumeric codes case-insensitively", () => {
|
||||||
|
expect(isValidInviteCode("ABCD1234")).toBe(true);
|
||||||
|
expect(isValidInviteCode("abcd1234")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects codes with the wrong length or characters", () => {
|
||||||
|
expect(isValidInviteCode("ABC123")).toBe(false); // too short
|
||||||
|
expect(isValidInviteCode("ABCD12345")).toBe(false); // too long
|
||||||
|
expect(isValidInviteCode("ABCD-123")).toBe(false); // invalid character
|
||||||
|
expect(isValidInviteCode("")).toBe(false);
|
||||||
|
expect(isValidInviteCode(null)).toBe(false);
|
||||||
|
expect(isValidInviteCode(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeInviteCode", () => {
|
||||||
|
it("trims and uppercases", () => {
|
||||||
|
expect(normalizeInviteCode(" abcd1234 ")).toBe("ABCD1234");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseInviteLinkCode", () => {
|
||||||
|
it("parses a universal link", () => {
|
||||||
|
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234")).toEqual({
|
||||||
|
code: "ABCD1234",
|
||||||
|
source: "universal_link",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a universal link with a trailing slash", () => {
|
||||||
|
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234/")).toEqual({
|
||||||
|
code: "ABCD1234",
|
||||||
|
source: "universal_link",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a custom scheme link", () => {
|
||||||
|
expect(parseInviteLinkCode("app.sauci://join?code=abcd1234")).toEqual({
|
||||||
|
code: "ABCD1234",
|
||||||
|
source: "scheme",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unrelated https URL", () => {
|
||||||
|
expect(parseInviteLinkCode("https://sauci.app/redeem")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unrelated host", () => {
|
||||||
|
expect(parseInviteLinkCode("https://example.com/join/abcd1234")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a custom scheme link with a badly-shaped code", () => {
|
||||||
|
expect(parseInviteLinkCode("app.sauci://join?code=123")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a custom scheme link missing the join host", () => {
|
||||||
|
expect(parseInviteLinkCode("app.sauci://login?token=abc")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for garbage input", () => {
|
||||||
|
expect(parseInviteLinkCode("not a url")).toBeNull();
|
||||||
|
expect(parseInviteLinkCode(null)).toBeNull();
|
||||||
|
expect(parseInviteLinkCode(undefined)).toBeNull();
|
||||||
|
expect(parseInviteLinkCode("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import {
|
||||||
|
stashPendingInviteCode,
|
||||||
|
getPendingInviteCode,
|
||||||
|
clearPendingInviteCode,
|
||||||
|
} from "../../lib/pendingInviteCode";
|
||||||
|
|
||||||
|
describe("pendingInviteCode", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stashes, retrieves, and clears a valid code", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
||||||
|
|
||||||
|
await clearPendingInviteCode();
|
||||||
|
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing has been stashed", async () => {
|
||||||
|
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
||||||
|
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
||||||
|
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ApiError } from "@/lib/apiClient";
|
||||||
|
import { quizApi } from "@/features/quiz/services/quizApi";
|
||||||
|
import { useQuizStore } from "@/store/quizStore";
|
||||||
|
import type { QuizSession } from "@/features/quiz/types";
|
||||||
|
|
||||||
|
jest.mock("@/features/quiz/services/quizApi", () => ({
|
||||||
|
quizApi: {
|
||||||
|
startSession: jest.fn(),
|
||||||
|
getCurrentSession: jest.fn(),
|
||||||
|
submitAnswers: jest.fn(),
|
||||||
|
getResult: jest.fn(),
|
||||||
|
},
|
||||||
|
getQuizErrorCode: (error: unknown) => {
|
||||||
|
const { ApiError: RealApiError } = jest.requireActual("@/lib/apiClient");
|
||||||
|
if (!(error instanceof RealApiError)) return null;
|
||||||
|
const details = (error as { details?: unknown }).details as { error?: { code?: string } } | undefined;
|
||||||
|
return details?.error?.code ?? null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function session(overrides: Partial<QuizSession> = {}): QuizSession {
|
||||||
|
return {
|
||||||
|
id: "session-1",
|
||||||
|
status: "active",
|
||||||
|
created_at: "2026-08-01T00:00:00.000Z",
|
||||||
|
completed_at: null,
|
||||||
|
score_percent: null,
|
||||||
|
questions: [
|
||||||
|
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
|
||||||
|
{ id: "q2", prompt_self: "Self 2", prompt_guess: "Guess 2", options: ["A", "B"] },
|
||||||
|
],
|
||||||
|
my_answers: [],
|
||||||
|
partner_completed: false,
|
||||||
|
i_completed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetStore = () =>
|
||||||
|
useQuizStore.setState({
|
||||||
|
session: null,
|
||||||
|
result: null,
|
||||||
|
localAnswers: {},
|
||||||
|
isLoading: false,
|
||||||
|
isStarting: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isLoadingResult: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: null,
|
||||||
|
generation: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quizStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new session and resets local answers/result", async () => {
|
||||||
|
(quizApi.startSession as jest.Mock).mockResolvedValue({ session: session() });
|
||||||
|
await useQuizStore.getState().start();
|
||||||
|
|
||||||
|
expect(useQuizStore.getState().session?.id).toBe("session-1");
|
||||||
|
expect(useQuizStore.getState().localAnswers).toEqual({});
|
||||||
|
expect(useQuizStore.getState().isStarting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records local self and guess answers per question without submitting early", () => {
|
||||||
|
useQuizStore.setState({ session: session() });
|
||||||
|
useQuizStore.getState().setSelfAnswer("q1", 0);
|
||||||
|
useQuizStore.getState().setGuessAnswer("q1", 1);
|
||||||
|
|
||||||
|
expect(useQuizStore.getState().localAnswers.q1).toEqual({ self_index: 0, guess_index: 1 });
|
||||||
|
expect(quizApi.submitAnswers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the full answer set built from local answers and stores the returned session", async () => {
|
||||||
|
useQuizStore.setState({
|
||||||
|
session: session(),
|
||||||
|
localAnswers: {
|
||||||
|
q1: { self_index: 0, guess_index: 1 },
|
||||||
|
q2: { self_index: 1, guess_index: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updated = session({ i_completed: true });
|
||||||
|
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({ session: updated });
|
||||||
|
|
||||||
|
const ok = await useQuizStore.getState().submit();
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
|
||||||
|
{ question_id: "q1", self_index: 0, guess_index: 1 },
|
||||||
|
{ question_id: "q2", self_index: 1, guess_index: 0 },
|
||||||
|
]);
|
||||||
|
expect(useQuizStore.getState().session?.i_completed).toBe(true);
|
||||||
|
expect(useQuizStore.getState().localAnswers).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a pairing error code instead of a generic message", async () => {
|
||||||
|
(quizApi.getCurrentSession as jest.Mock).mockRejectedValue(
|
||||||
|
new ApiError("no_couple", 409, { error: { code: "no_couple" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useQuizStore.getState().load();
|
||||||
|
|
||||||
|
expect(useQuizStore.getState().errorCode).toBe("no_couple");
|
||||||
|
expect(useQuizStore.getState().error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearQuiz resets all state and invalidates any in-flight request (sign-out)", async () => {
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
(quizApi.getCurrentSession as jest.Mock).mockReturnValueOnce(
|
||||||
|
new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const staleLoad = useQuizStore.getState().load();
|
||||||
|
|
||||||
|
useQuizStore.getState().clearQuiz();
|
||||||
|
resolve({ session: session({ id: "stale-session" }) });
|
||||||
|
await staleLoad;
|
||||||
|
|
||||||
|
expect(useQuizStore.getState().session).toBeNull();
|
||||||
|
expect(useQuizStore.getState().generation).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, radius, typography, shadows, featureColors } from '../../theme';
|
||||||
|
import { useAuthStore } from '../../store';
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = spacing.lg;
|
||||||
|
const TILE_HEIGHT = 100;
|
||||||
|
const ACCENT = featureColors.quiz.accent;
|
||||||
|
|
||||||
|
interface QuizTileProps {
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizTile({ delay = 0 }: QuizTileProps) {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const paired = Boolean(user?.couple_id);
|
||||||
|
|
||||||
|
if (!paired) return null;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
router.push('/(app)/quiz' as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500).springify()}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
|
||||||
|
<View style={[styles.tile, shadows.md]}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="help-circle" size={24} color="rgba(255, 255, 255, 0.9)" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>Quiz</Text>
|
||||||
|
<Text style={styles.subtitle}>How well do you know each other?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
|
||||||
|
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
tileWrapper: {
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
},
|
||||||
|
tile: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: ACCENT,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
height: TILE_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,4 +4,5 @@ export { CompactHeader } from './CompactHeader';
|
|||||||
export { CircularProgress } from './CircularProgress';
|
export { CircularProgress } from './CircularProgress';
|
||||||
export { LiveDrawTile } from './LiveDrawTile';
|
export { LiveDrawTile } from './LiveDrawTile';
|
||||||
export { DaresTile } from './DaresTile';
|
export { DaresTile } from './DaresTile';
|
||||||
|
export { QuizTile } from './QuizTile';
|
||||||
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
||||||
@@ -30,6 +30,7 @@ interface GlassButtonProps {
|
|||||||
textStyle?: TextStyle;
|
textStyle?: TextStyle;
|
||||||
haptic?: boolean;
|
haptic?: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||||
@@ -46,6 +47,7 @@ export function GlassButton({
|
|||||||
textStyle,
|
textStyle,
|
||||||
haptic = true,
|
haptic = true,
|
||||||
icon,
|
icon,
|
||||||
|
testID,
|
||||||
}: GlassButtonProps) {
|
}: GlassButtonProps) {
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ export function GlassButton({
|
|||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
testID={testID}
|
||||||
style={[
|
style={[
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
fullWidth && styles.fullWidth,
|
fullWidth && styles.fullWidth,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import { useCanvasCapture } from './useCanvasCapture';
|
||||||
|
|
||||||
|
jest.mock('expo-media-library', () => ({
|
||||||
|
requestPermissionsAsync: jest.fn(),
|
||||||
|
saveToLibraryAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
|
cacheDirectory: 'file:///cache/',
|
||||||
|
writeAsStringAsync: jest.fn(),
|
||||||
|
EncodingType: { Base64: 'base64' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useCanvasCapture', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||||
|
(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests add-only permission before saving the captured image', async () => {
|
||||||
|
const { result } = renderHook(() => useCanvasCapture());
|
||||||
|
result.current.makeSnapshot.current = () => ({
|
||||||
|
encodeToBase64: () => 'encoded-image',
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
let saved = false;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.saveToGallery();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
expect(MediaLibrary.requestPermissionsAsync).toHaveBeenCalledWith(true);
|
||||||
|
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/)
|
||||||
|
);
|
||||||
|
expect(FileSystem.writeAsStringAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/),
|
||||||
|
'encoded-image',
|
||||||
|
{ encoding: 'base64' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,9 @@ export function useCanvasCapture(): UseCanvasCaptureReturn {
|
|||||||
const fileUri = await captureToFile();
|
const fileUri = await captureToFile();
|
||||||
if (!fileUri) return false;
|
if (!fileUri) return false;
|
||||||
|
|
||||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
// The capture is created by Sauci, so request add-only access rather than
|
||||||
|
// broad photo-library read access.
|
||||||
|
const { status } = await MediaLibrary.requestPermissionsAsync(true);
|
||||||
if (status !== 'granted') return false;
|
if (status !== 'granted') return false;
|
||||||
|
|
||||||
await MediaLibrary.saveToLibraryAsync(fileUri);
|
await MediaLibrary.saveToLibraryAsync(fileUri);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
||||||
|
import { GradientBackground } from "../../components/ui";
|
||||||
|
import { featureColors } from "../../theme";
|
||||||
|
import { SwipeNoPartnerState } from "../swipe/components/SwipeNoPartnerState";
|
||||||
|
import { useQuizScreen } from "./hooks/useQuizScreen";
|
||||||
|
import {
|
||||||
|
QuizIntro,
|
||||||
|
QuizQuestionCard,
|
||||||
|
QuizResults,
|
||||||
|
QuizShareModal,
|
||||||
|
QuizWaitingState,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
|
export function QuizScreen() {
|
||||||
|
const {
|
||||||
|
screenState,
|
||||||
|
session,
|
||||||
|
result,
|
||||||
|
currentStep,
|
||||||
|
totalQuestions,
|
||||||
|
isStarting,
|
||||||
|
isSubmitting,
|
||||||
|
isLoadingResult,
|
||||||
|
shareModalVisible,
|
||||||
|
handleSelectOption,
|
||||||
|
handleStart,
|
||||||
|
handleNewQuiz,
|
||||||
|
handleRefresh,
|
||||||
|
handlePairPress,
|
||||||
|
handleNudgePartner,
|
||||||
|
openShareModal,
|
||||||
|
closeShareModal,
|
||||||
|
} = useQuizScreen();
|
||||||
|
|
||||||
|
if (screenState === "not_paired") {
|
||||||
|
return <SwipeNoPartnerState hasCouple={false} onPairPress={handlePairPress} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "loading") {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "intro") {
|
||||||
|
return <QuizIntro isStarting={isStarting} onStart={handleStart} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "waiting") {
|
||||||
|
return <QuizWaitingState onNudgePartner={handleNudgePartner} onRefresh={handleRefresh} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenState === "results") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<QuizResults
|
||||||
|
result={result}
|
||||||
|
isLoadingResult={isLoadingResult}
|
||||||
|
isStarting={isStarting}
|
||||||
|
onNewQuiz={handleNewQuiz}
|
||||||
|
onShare={openShareModal}
|
||||||
|
/>
|
||||||
|
<QuizShareModal
|
||||||
|
visible={shareModalVisible}
|
||||||
|
onClose={closeShareModal}
|
||||||
|
scorePercent={result?.score_percent ?? 0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenState === "answering"
|
||||||
|
if (!session || !currentStep || isSubmitting) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuizQuestionCard
|
||||||
|
question={currentStep.question}
|
||||||
|
step={currentStep.step}
|
||||||
|
questionNumber={currentStep.questionNumber}
|
||||||
|
totalQuestions={totalQuestions}
|
||||||
|
onSelectOption={handleSelectOption}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default QuizScreen;
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
import { GlassButton, GradientBackground } from "../../../components/ui";
|
||||||
|
import { colors, featureColors, radius, shadows, spacing, typography } from "../../../theme";
|
||||||
|
|
||||||
|
const ACCENT = featureColors.quiz.accent;
|
||||||
|
|
||||||
|
interface QuizIntroProps {
|
||||||
|
isStarting: boolean;
|
||||||
|
onStart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizIntro({ isStarting, onStart }: QuizIntroProps) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Animated.View entering={FadeInDown.duration(600).springify()} style={styles.content}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={featureColors.quiz.gradient as [string, string]}
|
||||||
|
style={styles.iconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="help-circle" size={40} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
<Text style={styles.label}>CONNECTION</Text>
|
||||||
|
<Text style={styles.title}>How well do you know each other?</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Answer a set of questions about yourself, then guess how your partner
|
||||||
|
answered. See how well you really know each other, and share your score.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Ionicons name="list-outline" size={14} color={ACCENT} />
|
||||||
|
<Text style={styles.badgeText}>10 questions</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={onStart}
|
||||||
|
loading={isStarting}
|
||||||
|
disabled={isStarting}
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
style={styles.startButton}
|
||||||
|
testID="quiz-start"
|
||||||
|
>
|
||||||
|
Start the quiz
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
iconGradient: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
borderRadius: 44,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 3,
|
||||||
|
color: ACCENT,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.title1,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: ACCENT,
|
||||||
|
},
|
||||||
|
startButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
import { GlassCard, GradientBackground } from "../../../components/ui";
|
||||||
|
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||||
|
import type { QuizQuestion } from "../types";
|
||||||
|
import type { QuizAnswerStep } from "../hooks/useQuizScreen";
|
||||||
|
|
||||||
|
const ACCENT = featureColors.quiz.accent;
|
||||||
|
|
||||||
|
interface QuizQuestionCardProps {
|
||||||
|
question: QuizQuestion;
|
||||||
|
step: QuizAnswerStep;
|
||||||
|
questionNumber: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
onSelectOption: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizQuestionCard({
|
||||||
|
question,
|
||||||
|
step,
|
||||||
|
questionNumber,
|
||||||
|
totalQuestions,
|
||||||
|
onSelectOption,
|
||||||
|
}: QuizQuestionCardProps) {
|
||||||
|
const prompt = step === "self" ? question.prompt_self : question.prompt_guess;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.progress} testID="quiz-progress">
|
||||||
|
{questionNumber} of {totalQuestions}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.stepBadge}>
|
||||||
|
<Text style={styles.stepBadgeText}>{step === "self" ? "YOU" : "YOUR PARTNER"}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View entering={FadeInDown.duration(400).springify()} key={`${question.id}-${step}`}>
|
||||||
|
<GlassCard variant="elevated" style={styles.card}>
|
||||||
|
<Text style={styles.prompt}>{prompt}</Text>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<View style={styles.options}>
|
||||||
|
{question.options.map((option, index) => (
|
||||||
|
<Pressable
|
||||||
|
key={`${question.id}-${index}`}
|
||||||
|
onPress={() => onSelectOption(index)}
|
||||||
|
style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
|
||||||
|
testID={`quiz-option-${index}`}
|
||||||
|
>
|
||||||
|
<Text style={styles.optionText}>{option}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: 80,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
stepBadge: {
|
||||||
|
alignSelf: "center",
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
stepBadgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: ACCENT,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
...typography.title2,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
option: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
optionPressed: {
|
||||||
|
borderColor: ACCENT,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
|
import { GlassButton, GlassCard, GradientBackground } from "../../../components/ui";
|
||||||
|
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||||
|
import type { QuizResult } from "../types";
|
||||||
|
|
||||||
|
interface QuizResultsProps {
|
||||||
|
result: QuizResult | null;
|
||||||
|
isLoadingResult: boolean;
|
||||||
|
isStarting: boolean;
|
||||||
|
onNewQuiz: () => void;
|
||||||
|
onShare: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizResults({ result, isLoadingResult, isStarting, onNewQuiz, onShare }: QuizResultsProps) {
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
|
||||||
|
<Text style={styles.loadingText}>
|
||||||
|
{isLoadingResult ? "Tallying your answers..." : "Loading your results..."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||||
|
<Animated.View entering={FadeInDown.duration(500).springify()}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={featureColors.quiz.gradient as [string, string]}
|
||||||
|
style={styles.hero}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.heroLabel}>YOU MATCHED ON</Text>
|
||||||
|
<Text style={styles.heroScore} testID="quiz-score">
|
||||||
|
{result.score_percent}%
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.heroTagline}>How well do you know each other?</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.breakdown}>
|
||||||
|
{result.questions.map((question) => (
|
||||||
|
<GlassCard key={question.question_id} style={styles.questionCard}>
|
||||||
|
<Text style={styles.questionPrompt}>{question.prompt_self}</Text>
|
||||||
|
<View style={styles.resultRow}>
|
||||||
|
<Ionicons
|
||||||
|
name={question.i_guessed_right ? "checkmark-circle" : "close-circle"}
|
||||||
|
size={18}
|
||||||
|
color={question.i_guessed_right ? colors.success : colors.error}
|
||||||
|
/>
|
||||||
|
<Text style={styles.resultText}>
|
||||||
|
{question.i_guessed_right ? "You guessed right" : "You guessed wrong"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.resultRow}>
|
||||||
|
<Ionicons
|
||||||
|
name={question.partner_guessed_right ? "checkmark-circle" : "close-circle"}
|
||||||
|
size={18}
|
||||||
|
color={question.partner_guessed_right ? colors.success : colors.error}
|
||||||
|
/>
|
||||||
|
<Text style={styles.resultText}>
|
||||||
|
{question.partner_guessed_right
|
||||||
|
? "Your partner guessed right"
|
||||||
|
: "Your partner guessed wrong"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={onShare}
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
style={styles.shareButton}
|
||||||
|
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
|
||||||
|
testID="quiz-share"
|
||||||
|
>
|
||||||
|
Share your score
|
||||||
|
</GlassButton>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={onNewQuiz}
|
||||||
|
loading={isStarting}
|
||||||
|
disabled={isStarting}
|
||||||
|
fullWidth
|
||||||
|
style={styles.newQuizButton}
|
||||||
|
>
|
||||||
|
Take another quiz
|
||||||
|
</GlassButton>
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingBottom: spacing.xxl,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
paddingVertical: spacing.xl,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
heroLabel: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 3,
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
heroScore: {
|
||||||
|
...typography.largeTitle,
|
||||||
|
fontSize: 56,
|
||||||
|
lineHeight: 64,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
heroTagline: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
breakdown: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
questionCard: {
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
questionPrompt: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
resultRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
resultText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
newQuizButton: {},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { render } from "@/test/test-utils";
|
||||||
|
import { QuizResultsShareCard } from "./QuizResultsShareCard";
|
||||||
|
|
||||||
|
describe("QuizResultsShareCard", () => {
|
||||||
|
it("renders the score and Sauci watermark without any question content", () => {
|
||||||
|
const { getByText, queryByText } = render(
|
||||||
|
<QuizResultsShareCard scorePercent={84} cardWidth={300} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText("84%")).toBeTruthy();
|
||||||
|
expect(getByText("How well do you know each other?")).toBeTruthy();
|
||||||
|
expect(getByText("sauci.app")).toBeTruthy();
|
||||||
|
expect(queryByText(/prompt/i)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Image, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { featureColors, radius, spacing, typography } from "../../../theme";
|
||||||
|
|
||||||
|
const logo = require("../../../../assets/logo.png");
|
||||||
|
|
||||||
|
interface QuizResultsShareCardProps {
|
||||||
|
scorePercent: number;
|
||||||
|
cardWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card captured for sharing. Deliberately carries only the score, never the
|
||||||
|
* question text or answers, so a screenshot cannot leak either partner's content.
|
||||||
|
*/
|
||||||
|
export function QuizResultsShareCard({ scorePercent, cardWidth }: QuizResultsShareCardProps) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, { width: cardWidth }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={featureColors.quiz.gradient as [string, string]}
|
||||||
|
style={styles.gradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.tagline}>How well do you know each other?</Text>
|
||||||
|
<Text style={styles.label}>WE MATCHED ON</Text>
|
||||||
|
<Text style={styles.score}>{scorePercent}%</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.branding}>
|
||||||
|
<Image source={logo} style={styles.brandingLogo} />
|
||||||
|
<Text style={styles.brandingText}>sauci.app</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
aspectRatio: 0.7,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
flex: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 3,
|
||||||
|
color: "rgba(255, 255, 255, 0.85)",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
...typography.largeTitle,
|
||||||
|
fontSize: 64,
|
||||||
|
lineHeight: 72,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#ffffff",
|
||||||
|
},
|
||||||
|
branding: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
brandingLogo: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
resizeMode: "contain",
|
||||||
|
},
|
||||||
|
brandingText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Share,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from "react-native";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import ViewShot from "react-native-view-shot";
|
||||||
|
import * as Sharing from "expo-sharing";
|
||||||
|
import * as FileSystem from "expo-file-system/legacy";
|
||||||
|
import { colors, spacing, typography, blur } from "../../../theme";
|
||||||
|
import { Events } from "../../../lib/analytics";
|
||||||
|
import { QuizResultsShareCard } from "./QuizResultsShareCard";
|
||||||
|
|
||||||
|
const MAX_CARD_WIDTH = 340;
|
||||||
|
|
||||||
|
interface QuizShareModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
scorePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModalProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
|
||||||
|
const shareViewRef = useRef<ViewShot>(null);
|
||||||
|
const [capturedUri, setCapturedUri] = useState<string | null>(null);
|
||||||
|
const [isCapturing, setIsCapturing] = useState(false);
|
||||||
|
|
||||||
|
const shareMessage = `We matched on ${scorePercent}% on our couple quiz! Discover more on sauci.app`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && shareViewRef.current) {
|
||||||
|
setIsCapturing(true);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const uri = await shareViewRef.current?.capture?.();
|
||||||
|
if (uri) setCapturedUri(uri);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error capturing quiz share image:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCapturing(false);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) setCapturedUri(null);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleInstagramShare = async () => {
|
||||||
|
if (!capturedUri) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instagramUrl = "instagram://app";
|
||||||
|
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
|
||||||
|
|
||||||
|
if (canOpenInstagram && Platform.OS === "ios") {
|
||||||
|
await FileSystem.readAsStringAsync(capturedUri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app";
|
||||||
|
await Linking.openURL(instagramStoriesUrl);
|
||||||
|
Events.quizShared();
|
||||||
|
} else {
|
||||||
|
await handleMoreShare();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Instagram share error:", error);
|
||||||
|
await handleMoreShare();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessagesShare = async () => {
|
||||||
|
if (!capturedUri) {
|
||||||
|
await Share.share({ message: shareMessage });
|
||||||
|
Events.quizShared();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", UTI: "public.png" });
|
||||||
|
Events.quizShared();
|
||||||
|
} else {
|
||||||
|
await handleMoreShare();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Messages share error:", error);
|
||||||
|
await Share.share({ message: shareMessage });
|
||||||
|
Events.quizShared();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoreShare = async () => {
|
||||||
|
if (!capturedUri) {
|
||||||
|
await Share.share({ message: shareMessage });
|
||||||
|
Events.quizShared();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAvailable = await Sharing.isAvailableAsync();
|
||||||
|
if (isAvailable) {
|
||||||
|
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", dialogTitle: "Share your quiz score" });
|
||||||
|
} else {
|
||||||
|
await Share.share({ message: shareMessage });
|
||||||
|
}
|
||||||
|
Events.quizShared();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Share error:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useBlur = Platform.OS === "ios";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
{useBlur ? (
|
||||||
|
<BlurView intensity={blur.heavy} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.backdropFallback} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={SlideInDown.springify().damping(20)}
|
||||||
|
exiting={SlideOutDown.duration(200)}
|
||||||
|
style={[styles.content, { paddingTop: insets.top }]}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
|
||||||
|
testID="quiz-share-close"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={28} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title}>Share your score</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewContainer}>
|
||||||
|
<ViewShot ref={shareViewRef} options={{ format: "png", quality: 1, result: "tmpfile" }}>
|
||||||
|
<QuizResultsShareCard scorePercent={scorePercent} cardWidth={cardWidth} />
|
||||||
|
</ViewShot>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
|
||||||
|
<ShareActionButton
|
||||||
|
icon="logo-instagram"
|
||||||
|
label="Instagram"
|
||||||
|
onPress={handleInstagramShare}
|
||||||
|
disabled={isCapturing}
|
||||||
|
/>
|
||||||
|
<ShareActionButton
|
||||||
|
icon="chatbubble-ellipses"
|
||||||
|
label="Messages"
|
||||||
|
onPress={handleMessagesShare}
|
||||||
|
disabled={isCapturing}
|
||||||
|
/>
|
||||||
|
<ShareActionButton
|
||||||
|
icon="share-outline"
|
||||||
|
label="More"
|
||||||
|
onPress={handleMoreShare}
|
||||||
|
disabled={isCapturing}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareActionButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
testID={`quiz-share-${icon}`}
|
||||||
|
>
|
||||||
|
<View style={styles.actionIconContainer}>
|
||||||
|
<Ionicons name={icon as any} size={28} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
backdropFallback: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(14, 14, 17, 0.95)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
width: 44,
|
||||||
|
},
|
||||||
|
previewContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
actionsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xl,
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { SwipeInfoStateLayout } from "../../swipe/components/SwipeInfoStateLayout";
|
||||||
|
import { colors } from "../../../theme";
|
||||||
|
|
||||||
|
interface QuizWaitingStateProps {
|
||||||
|
onNudgePartner: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuizWaitingState({ onNudgePartner, onRefresh }: QuizWaitingStateProps) {
|
||||||
|
return (
|
||||||
|
<SwipeInfoStateLayout
|
||||||
|
accentColor={colors.premium.rose}
|
||||||
|
icon="checkmark-circle"
|
||||||
|
label="NICE WORK"
|
||||||
|
title="Your answers are in"
|
||||||
|
badgeText="WAITING ON YOUR PARTNER"
|
||||||
|
description="Nudge your partner to finish the quiz so you can both see how well you know each other."
|
||||||
|
features={[
|
||||||
|
{ icon: "hourglass-outline", text: "Results unlock once you both finish" },
|
||||||
|
{ icon: "heart-outline", text: "Share your match score together" },
|
||||||
|
]}
|
||||||
|
teaser="Almost there"
|
||||||
|
action={{ label: "Nudge your partner", onPress: onNudgePartner }}
|
||||||
|
secondaryAction={{ label: "Refresh", onPress: onRefresh, icon: "refresh-outline" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { QuizIntro } from "./QuizIntro";
|
||||||
|
export { QuizQuestionCard } from "./QuizQuestionCard";
|
||||||
|
export { QuizWaitingState } from "./QuizWaitingState";
|
||||||
|
export { QuizResults } from "./QuizResults";
|
||||||
|
export { QuizResultsShareCard } from "./QuizResultsShareCard";
|
||||||
|
export { QuizShareModal } from "./QuizShareModal";
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||||
|
import { useQuizScreen } from "./useQuizScreen";
|
||||||
|
import { useQuizStore } from "../../../store/quizStore";
|
||||||
|
import { quizApi } from "../services/quizApi";
|
||||||
|
import type { QuizSession } from "../types";
|
||||||
|
|
||||||
|
const mockRouterPush = jest.fn();
|
||||||
|
jest.mock("expo-router", () => {
|
||||||
|
const React = jest.requireActual("react");
|
||||||
|
return {
|
||||||
|
useRouter: () => ({ push: mockRouterPush }),
|
||||||
|
// Mirrors react-navigation's real semantics closely enough for this hook:
|
||||||
|
// runs once per stable callback identity, not on every render.
|
||||||
|
useFocusEffect: (callback: () => void) => React.useEffect(callback, [callback]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockAuth = { user: { couple_id: null as string | null }, partner: null as { id: string } | null };
|
||||||
|
jest.mock("../../../store", () => ({
|
||||||
|
useAuthStore: () => mockAuth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../services/quizApi", () => ({
|
||||||
|
quizApi: {
|
||||||
|
startSession: jest.fn(),
|
||||||
|
getCurrentSession: jest.fn(),
|
||||||
|
submitAnswers: jest.fn(),
|
||||||
|
getResult: jest.fn(),
|
||||||
|
},
|
||||||
|
getQuizErrorCode: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function session(overrides: Partial<QuizSession> = {}): QuizSession {
|
||||||
|
return {
|
||||||
|
id: "session-1",
|
||||||
|
status: "active",
|
||||||
|
created_at: "2026-08-01T00:00:00.000Z",
|
||||||
|
completed_at: null,
|
||||||
|
score_percent: null,
|
||||||
|
questions: [
|
||||||
|
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
|
||||||
|
],
|
||||||
|
my_answers: [],
|
||||||
|
partner_completed: false,
|
||||||
|
i_completed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useQuizScreen", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
useQuizStore.setState({
|
||||||
|
session: null, result: null, localAnswers: {},
|
||||||
|
isLoading: false, isStarting: false, isSubmitting: false, isLoadingResult: false,
|
||||||
|
errorCode: null, error: null, generation: 0,
|
||||||
|
});
|
||||||
|
mockAuth = { user: { couple_id: null }, partner: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the not_paired state when the user has no couple or partner", async () => {
|
||||||
|
const { result } = renderHook(() => useQuizScreen());
|
||||||
|
expect(result.current.screenState).toBe("not_paired");
|
||||||
|
|
||||||
|
act(() => result.current.handlePairPress());
|
||||||
|
expect(mockRouterPush).toHaveBeenCalledWith("/pairing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves to intro once paired and no active session exists", async () => {
|
||||||
|
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
|
||||||
|
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: null });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizScreen());
|
||||||
|
await waitFor(() => expect(result.current.screenState).toBe("intro"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks self then guess for each question and submits once all are answered", async () => {
|
||||||
|
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
|
||||||
|
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: session() });
|
||||||
|
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({
|
||||||
|
session: session({ i_completed: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizScreen());
|
||||||
|
await waitFor(() => expect(result.current.screenState).toBe("answering"));
|
||||||
|
expect(result.current.currentStep?.step).toBe("self");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSelectOption(0);
|
||||||
|
});
|
||||||
|
expect(result.current.currentStep?.step).toBe("guess");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSelectOption(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
|
||||||
|
{ question_id: "q1", self_index: 0, guess_index: 1 },
|
||||||
|
]);
|
||||||
|
await waitFor(() => expect(result.current.screenState).toBe("waiting"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows results once the session and both partners have completed", async () => {
|
||||||
|
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
|
||||||
|
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({
|
||||||
|
session: session({ status: "completed", i_completed: true, partner_completed: true, score_percent: 80 }),
|
||||||
|
});
|
||||||
|
(quizApi.getResult as jest.Mock).mockResolvedValue({
|
||||||
|
score_percent: 80, completed_at: "now", questions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuizScreen());
|
||||||
|
await waitFor(() => expect(result.current.screenState).toBe("results"));
|
||||||
|
await waitFor(() => expect(result.current.result?.score_percent).toBe(80));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Share } from "react-native";
|
||||||
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
|
import { useAuthStore } from "../../../store";
|
||||||
|
import { useQuizStore } from "../../../store/quizStore";
|
||||||
|
import { Events } from "../../../lib/analytics";
|
||||||
|
import type { QuizQuestion } from "../types";
|
||||||
|
|
||||||
|
export type QuizScreenState = "not_paired" | "loading" | "intro" | "answering" | "waiting" | "results";
|
||||||
|
export type QuizAnswerStep = "self" | "guess";
|
||||||
|
|
||||||
|
export interface CurrentQuizStep {
|
||||||
|
question: QuizQuestion;
|
||||||
|
step: QuizAnswerStep;
|
||||||
|
questionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuizScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, partner } = useAuthStore();
|
||||||
|
const {
|
||||||
|
session,
|
||||||
|
result,
|
||||||
|
localAnswers,
|
||||||
|
isLoading,
|
||||||
|
isStarting,
|
||||||
|
isSubmitting,
|
||||||
|
isLoadingResult,
|
||||||
|
errorCode,
|
||||||
|
load,
|
||||||
|
start,
|
||||||
|
setSelfAnswer,
|
||||||
|
setGuessAnswer,
|
||||||
|
submit,
|
||||||
|
loadResult,
|
||||||
|
} = useQuizStore();
|
||||||
|
const [shareModalVisible, setShareModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const hasCouple = Boolean(user?.couple_id);
|
||||||
|
const hasPartner = Boolean(partner);
|
||||||
|
const paired = hasCouple && hasPartner;
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (paired) void load();
|
||||||
|
}, [paired, load]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCompleted = session?.status === "completed";
|
||||||
|
const shouldShowResults = isCompleted || (session?.i_completed && session?.partner_completed);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowResults && session && !result && !isLoadingResult) {
|
||||||
|
void loadResult();
|
||||||
|
}
|
||||||
|
}, [shouldShowResults, session, result, isLoadingResult, loadResult]);
|
||||||
|
|
||||||
|
const screenState: QuizScreenState = useMemo(() => {
|
||||||
|
if (!paired) return "not_paired";
|
||||||
|
if (isLoading && !session) return "loading";
|
||||||
|
if (!session) return "intro";
|
||||||
|
if (shouldShowResults) return "results";
|
||||||
|
if (session.i_completed && !session.partner_completed) return "waiting";
|
||||||
|
return "answering";
|
||||||
|
}, [paired, isLoading, session, shouldShowResults]);
|
||||||
|
|
||||||
|
// The first unanswered step across the question set, in order.
|
||||||
|
const currentStep: CurrentQuizStep | null = useMemo(() => {
|
||||||
|
if (!session) return null;
|
||||||
|
for (let i = 0; i < session.questions.length; i += 1) {
|
||||||
|
const question = session.questions[i];
|
||||||
|
const answer = localAnswers[question.id];
|
||||||
|
if (answer?.self_index === undefined) {
|
||||||
|
return { question, step: "self", questionNumber: i + 1 };
|
||||||
|
}
|
||||||
|
if (answer?.guess_index === undefined) {
|
||||||
|
return { question, step: "guess", questionNumber: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [session, localAnswers]);
|
||||||
|
|
||||||
|
const totalQuestions = session?.questions.length ?? 0;
|
||||||
|
|
||||||
|
const handleSelectOption = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!currentStep) return;
|
||||||
|
const { question, step } = currentStep;
|
||||||
|
if (step === "self") {
|
||||||
|
setSelfAnswer(question.id, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGuessAnswer(question.id, index);
|
||||||
|
|
||||||
|
// The guess just recorded may be the last one needed; submit as soon as
|
||||||
|
// every question has both a self and a guess answer.
|
||||||
|
const answers = useQuizStore.getState().localAnswers;
|
||||||
|
const isLastQuestion = totalQuestions > 0 && currentStep.questionNumber === totalQuestions;
|
||||||
|
if (isLastQuestion) {
|
||||||
|
const allAnswered = session!.questions.every((q) => {
|
||||||
|
const a = q.id === question.id ? { ...answers[q.id], guess_index: index } : answers[q.id];
|
||||||
|
return a?.self_index !== undefined && a?.guess_index !== undefined;
|
||||||
|
});
|
||||||
|
if (allAnswered) {
|
||||||
|
await submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentStep, setSelfAnswer, setGuessAnswer, submit, totalQuestions, session],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStart = useCallback(() => {
|
||||||
|
void start();
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
const handleNewQuiz = useCallback(() => {
|
||||||
|
void start();
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handlePairPress = useCallback(() => {
|
||||||
|
router.push("/pairing");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleNudgePartner = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: "I just finished our couple quiz on Sauci, your turn!",
|
||||||
|
});
|
||||||
|
Events.quizPartnerNudged();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error nudging partner:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openShareModal = useCallback(() => setShareModalVisible(true), []);
|
||||||
|
const closeShareModal = useCallback(() => setShareModalVisible(false), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenState,
|
||||||
|
session,
|
||||||
|
result,
|
||||||
|
currentStep,
|
||||||
|
totalQuestions,
|
||||||
|
isStarting,
|
||||||
|
isSubmitting,
|
||||||
|
isLoadingResult,
|
||||||
|
isLoading,
|
||||||
|
errorCode,
|
||||||
|
hasCouple,
|
||||||
|
shareModalVisible,
|
||||||
|
handleSelectOption,
|
||||||
|
handleStart,
|
||||||
|
handleNewQuiz,
|
||||||
|
handleRefresh,
|
||||||
|
handlePairPress,
|
||||||
|
handleNudgePartner,
|
||||||
|
openShareModal,
|
||||||
|
closeShareModal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { QuizScreen, default } from "./QuizScreen";
|
||||||
|
export { useQuizScreen } from "./hooks/useQuizScreen";
|
||||||
|
export { quizApi, getQuizErrorCode } from "./services/quizApi";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { apiClient, ApiError } from "../../../lib/apiClient";
|
||||||
|
import { getQuizErrorCode, quizApi } from "./quizApi";
|
||||||
|
|
||||||
|
jest.mock("../../../lib/apiClient", () => {
|
||||||
|
const actual = jest.requireActual("../../../lib/apiClient");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
apiClient: { get: jest.fn(), post: jest.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = apiClient.get as jest.Mock;
|
||||||
|
const post = apiClient.post as jest.Mock;
|
||||||
|
|
||||||
|
describe("quizApi", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a session against the couple quiz session endpoint", async () => {
|
||||||
|
post.mockResolvedValue({ session: { id: "s1" } });
|
||||||
|
await quizApi.startSession();
|
||||||
|
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the current session", async () => {
|
||||||
|
get.mockResolvedValue({ session: null });
|
||||||
|
await quizApi.getCurrentSession();
|
||||||
|
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/current");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the full answer set for a session", async () => {
|
||||||
|
post.mockResolvedValue({ session: { id: "s1" } });
|
||||||
|
const answers = [{ question_id: "q1", self_index: 0, guess_index: 1 }];
|
||||||
|
await quizApi.submitAnswers("s1", answers);
|
||||||
|
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions/s1/answers", { answers });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes the session id when reading a result", async () => {
|
||||||
|
get.mockResolvedValue({ score_percent: 80, completed_at: "now", questions: [] });
|
||||||
|
await quizApi.getResult("s1/weird");
|
||||||
|
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/s1%2Fweird/result");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getQuizErrorCode", () => {
|
||||||
|
it("reads the contract's { error: { code } } body off an ApiError", () => {
|
||||||
|
const error = new ApiError("no_couple", 409, { error: { code: "no_couple" } });
|
||||||
|
expect(getQuizErrorCode(error)).toBe("no_couple");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a non-ApiError or a body without a code", () => {
|
||||||
|
expect(getQuizErrorCode(new Error("boom"))).toBeNull();
|
||||||
|
expect(getQuizErrorCode(new ApiError("oops", 500, {}))).toBeNull();
|
||||||
|
expect(getQuizErrorCode(new ApiError("oops", 500, undefined))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { apiClient, ApiError } from "../../../lib/apiClient";
|
||||||
|
import type { QuizAnswer, QuizErrorCode, QuizResult, QuizSession } from "../types";
|
||||||
|
|
||||||
|
const sessionPath = (sessionId: string) => `/v1/quiz/sessions/${encodeURIComponent(sessionId)}`;
|
||||||
|
|
||||||
|
export const quizApi = {
|
||||||
|
startSession: () => apiClient.post<{ session: QuizSession }>("/v1/quiz/sessions"),
|
||||||
|
getCurrentSession: () => apiClient.get<{ session: QuizSession | null }>("/v1/quiz/sessions/current"),
|
||||||
|
submitAnswers: (sessionId: string, answers: QuizAnswer[]) =>
|
||||||
|
apiClient.post<{ session: QuizSession }>(`${sessionPath(sessionId)}/answers`, { answers }),
|
||||||
|
getResult: (sessionId: string) => apiClient.get<QuizResult>(`${sessionPath(sessionId)}/result`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the API's `{ error: { code } }` body out of an `ApiError`, so callers can
|
||||||
|
* branch on the contract's error codes instead of matching on message text.
|
||||||
|
*/
|
||||||
|
export function getQuizErrorCode(error: unknown): QuizErrorCode | null {
|
||||||
|
if (!(error instanceof ApiError)) return null;
|
||||||
|
const body = error.details as { error?: { code?: string } } | undefined;
|
||||||
|
const code = body?.error?.code;
|
||||||
|
return typeof code === "string" ? (code as QuizErrorCode) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizAnswer {
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizSession {
|
||||||
|
id: string;
|
||||||
|
status: "active" | "completed";
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
score_percent: number | null;
|
||||||
|
questions: QuizQuestion[];
|
||||||
|
my_answers: QuizAnswer[];
|
||||||
|
partner_completed: boolean;
|
||||||
|
i_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResultQuestion {
|
||||||
|
question_id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
my_self_index: number;
|
||||||
|
my_guess_index: number;
|
||||||
|
partner_self_index: number;
|
||||||
|
partner_guess_index: number;
|
||||||
|
i_guessed_right: boolean;
|
||||||
|
partner_guessed_right: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResult {
|
||||||
|
score_percent: number;
|
||||||
|
completed_at: string;
|
||||||
|
questions: QuizResultQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Known error codes the quiz API can surface as `{ error: { code } }`. */
|
||||||
|
export type QuizErrorCode =
|
||||||
|
| "no_couple"
|
||||||
|
| "partner_required"
|
||||||
|
| "session_not_found"
|
||||||
|
| "session_completed"
|
||||||
|
| "invalid_answers"
|
||||||
|
| "session_not_completed";
|
||||||
|
|
||||||
|
/** In-progress local answer for a question, before the full set is submitted. */
|
||||||
|
export interface QuizLocalAnswer {
|
||||||
|
self_index?: number;
|
||||||
|
guess_index?: number;
|
||||||
|
}
|
||||||
@@ -101,10 +101,14 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
|
|||||||
exif: false,
|
exif: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
// Android's system photo picker grants the selected file directly, so
|
||||||
if (status !== 'granted') {
|
// selecting an avatar does not need broad media-library access.
|
||||||
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
|
if (Platform.OS !== 'android') {
|
||||||
return;
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result = await ImagePicker.launchImageLibraryAsync({
|
result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ['images'],
|
mediaTypes: ['images'],
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy';
|
|||||||
|
|
||||||
export function useMediaSaver() {
|
export function useMediaSaver() {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();
|
// Saving app-created/downloaded media only needs add-only access. Passing
|
||||||
|
// writeOnly prevents this path from requesting broad library read access.
|
||||||
|
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ writeOnly: true });
|
||||||
|
|
||||||
const saveMedia = async (uri: string, type: 'image' | 'video') => {
|
const saveMedia = async (uri: string, type: 'image' | 'video') => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ export const Events = {
|
|||||||
|
|
||||||
// Invite events
|
// Invite events
|
||||||
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
||||||
|
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||||
|
logEvent("invite_link_opened", { source }),
|
||||||
|
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
||||||
|
|
||||||
|
// Quiz events
|
||||||
|
quizStarted: () => logEvent("quiz_started"),
|
||||||
|
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
|
||||||
|
quizShared: () => logEvent("quiz_shared"),
|
||||||
|
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
|
||||||
|
|
||||||
// Tutorial events
|
// Tutorial events
|
||||||
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Deferred hand-off fallback: when a user copies an invite code on another
|
||||||
|
// device (e.g. from the web join page) then opens the app, offer to use the
|
||||||
|
// code found on the clipboard. Never auto-applies the code; the caller must
|
||||||
|
// present a confirmation before using the result. Only checks once per app
|
||||||
|
// session so the user is not repeatedly prompted.
|
||||||
|
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||||
|
|
||||||
|
let hasCheckedThisSession = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the clipboard once per session for a string matching the invite
|
||||||
|
* code shape. Returns the normalized code, or null when there is nothing
|
||||||
|
* usable (or the check already ran this session).
|
||||||
|
*/
|
||||||
|
export async function checkClipboardForInviteCode(): Promise<string | null> {
|
||||||
|
if (hasCheckedThisSession) return null;
|
||||||
|
hasCheckedThisSession = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasString = await Clipboard.hasStringAsync();
|
||||||
|
if (!hasString) return null;
|
||||||
|
|
||||||
|
const value = await Clipboard.getStringAsync();
|
||||||
|
if (value && isValidInviteCode(value)) {
|
||||||
|
return normalizeInviteCode(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking clipboard for invite code:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only helper to reset the per-session gating between test cases. */
|
||||||
|
export function __resetClipboardInviteOfferForTests(): void {
|
||||||
|
hasCheckedThisSession = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Shared invite code shape and deep-link parsing for the invite funnel.
|
||||||
|
//
|
||||||
|
// Two link forms carry an invite code into the app:
|
||||||
|
// - Universal/App Link: https://sauci.app/join/{code}
|
||||||
|
// - Custom scheme link: app.sauci://join?code={code}
|
||||||
|
//
|
||||||
|
// Both forms are handled by the root layout's deep link listener and by the
|
||||||
|
// pairing screen when it receives a `code` route param.
|
||||||
|
|
||||||
|
export const INVITE_CODE_LENGTH = 8;
|
||||||
|
const INVITE_CODE_PATTERN = /^[A-Z0-9]{8}$/;
|
||||||
|
|
||||||
|
export type InviteLinkSource = "universal_link" | "scheme";
|
||||||
|
|
||||||
|
export interface ParsedInviteLink {
|
||||||
|
code: string;
|
||||||
|
source: InviteLinkSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `code` (case-insensitive) matches the 8-character
|
||||||
|
* alphanumeric invite code shape used across the app.
|
||||||
|
*/
|
||||||
|
export function isValidInviteCode(code: string | null | undefined): boolean {
|
||||||
|
if (!code) return false;
|
||||||
|
return INVITE_CODE_PATTERN.test(code.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw invite code string to the canonical uppercase form used
|
||||||
|
* for display, storage, and API calls.
|
||||||
|
*/
|
||||||
|
export function normalizeInviteCode(code: string): string {
|
||||||
|
return code.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an incoming URL for an invite code, supporting both the universal
|
||||||
|
* link form (https://sauci.app/join/{code}) and the custom scheme form
|
||||||
|
* (app.sauci://join?code={code}). Returns null when the URL does not carry a
|
||||||
|
* recognizable, validly-shaped invite code.
|
||||||
|
*/
|
||||||
|
export function parseInviteLinkCode(url: string | null | undefined): ParsedInviteLink | null {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawCode: string | null = null;
|
||||||
|
let source: InviteLinkSource | null = null;
|
||||||
|
|
||||||
|
if (parsed.protocol === "https:" && parsed.hostname === "sauci.app") {
|
||||||
|
const match = parsed.pathname.match(/^\/join\/([^/]+)\/?$/);
|
||||||
|
if (match) {
|
||||||
|
rawCode = match[1];
|
||||||
|
source = "universal_link";
|
||||||
|
}
|
||||||
|
} else if (parsed.protocol === "app.sauci:") {
|
||||||
|
const host = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
|
||||||
|
if (host === "join") {
|
||||||
|
rawCode = parsed.searchParams.get("code");
|
||||||
|
source = "scheme";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawCode || !source) return null;
|
||||||
|
|
||||||
|
const code = normalizeInviteCode(rawCode);
|
||||||
|
if (!isValidInviteCode(code)) return null;
|
||||||
|
|
||||||
|
return { code, source };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Deferred hand-off storage for an invite code received before the user is
|
||||||
|
// signed in or has finished onboarding. The code is applied once the user
|
||||||
|
// reaches the pairing screen (see app/(app)/pairing.tsx and app/index.tsx).
|
||||||
|
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "pending_invite_code";
|
||||||
|
|
||||||
|
export async function stashPendingInviteCode(code: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stashing pending invite code:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingInviteCode(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (value && isValidInviteCode(value)) {
|
||||||
|
return normalizeInviteCode(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading pending invite code:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPendingInviteCode(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error clearing pending invite code:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ const getOtherStores = () => {
|
|||||||
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
||||||
const { useStreakStore } = require("./streakStore");
|
const { useStreakStore } = require("./streakStore");
|
||||||
const { useResponsesStore } = require("./responsesStore");
|
const { useResponsesStore } = require("./responsesStore");
|
||||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
|
const { useQuizStore } = require("./quizStore");
|
||||||
|
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
console.log("[Auth] Session rejected by API, signing out");
|
console.log("[Auth] Session rejected by API, signing out");
|
||||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
useNotificationPreferencesStore.getState().clearPreferences();
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
useStreakStore.getState().clearStreak();
|
useStreakStore.getState().clearStreak();
|
||||||
useResponsesStore.getState().clearResponses();
|
useResponsesStore.getState().clearResponses();
|
||||||
|
useQuizStore.getState().clearQuiz();
|
||||||
await authClient.auth.signOut();
|
await authClient.auth.signOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
@@ -152,6 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
useNotificationPreferencesStore.getState().clearPreferences();
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
useStreakStore.getState().clearStreak();
|
useStreakStore.getState().clearStreak();
|
||||||
useResponsesStore.getState().clearResponses();
|
useResponsesStore.getState().clearResponses();
|
||||||
|
useQuizStore.getState().clearQuiz();
|
||||||
|
|
||||||
// Clear badge on sign out
|
// Clear badge on sign out
|
||||||
const { clearBadge } = require("../lib/badge");
|
const { clearBadge } = require("../lib/badge");
|
||||||
@@ -176,7 +179,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
@@ -184,6 +187,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
useNotificationPreferencesStore.getState().clearPreferences();
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
useStreakStore.getState().clearStreak();
|
useStreakStore.getState().clearStreak();
|
||||||
useResponsesStore.getState().clearResponses();
|
useResponsesStore.getState().clearResponses();
|
||||||
|
useQuizStore.getState().clearQuiz();
|
||||||
// Clear badge on sign out
|
// Clear badge on sign out
|
||||||
const { clearBadge } = require("../lib/badge");
|
const { clearBadge } = require("../lib/badge");
|
||||||
clearBadge();
|
clearBadge();
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { useNotificationPreferencesStore } from "./notificationPreferencesStore"
|
|||||||
export type { NotificationPreferences } from "./notificationPreferencesStore";
|
export type { NotificationPreferences } from "./notificationPreferencesStore";
|
||||||
export { useStreakStore } from "./streakStore";
|
export { useStreakStore } from "./streakStore";
|
||||||
export type { CoupleStreak } from "./streakStore";
|
export type { CoupleStreak } from "./streakStore";
|
||||||
|
export { useQuizStore } from "./quizStore";
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { Events } from "../lib/analytics";
|
||||||
|
import { getQuizErrorCode, quizApi } from "../features/quiz/services/quizApi";
|
||||||
|
import type { QuizAnswer, QuizErrorCode, QuizLocalAnswer, QuizResult, QuizSession } from "../features/quiz/types";
|
||||||
|
|
||||||
|
interface QuizState {
|
||||||
|
session: QuizSession | null;
|
||||||
|
result: QuizResult | null;
|
||||||
|
/** In-progress answers for the active session, keyed by question id, not yet submitted. */
|
||||||
|
localAnswers: Record<string, QuizLocalAnswer>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isStarting: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isLoadingResult: boolean;
|
||||||
|
/** Set when the couple/partner state blocks the quiz (`no_couple`, `partner_required`). */
|
||||||
|
errorCode: QuizErrorCode | null;
|
||||||
|
error: string | null;
|
||||||
|
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||||
|
generation: number;
|
||||||
|
|
||||||
|
load: () => Promise<void>;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
setSelfAnswer: (questionId: string, index: number) => void;
|
||||||
|
setGuessAnswer: (questionId: string, index: number) => void;
|
||||||
|
submit: () => Promise<boolean>;
|
||||||
|
loadResult: () => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
clearQuiz: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useQuizStore = create<QuizState>((set, get) => ({
|
||||||
|
session: null,
|
||||||
|
result: null,
|
||||||
|
localAnswers: {},
|
||||||
|
isLoading: false,
|
||||||
|
isStarting: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isLoadingResult: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: null,
|
||||||
|
generation: 0,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
const myGeneration = get().generation;
|
||||||
|
set({ isLoading: true, error: null, errorCode: null });
|
||||||
|
try {
|
||||||
|
const { session } = await quizApi.getCurrentSession();
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
set({ session, isLoading: false, localAnswers: {} });
|
||||||
|
} catch (cause) {
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
const code = getQuizErrorCode(cause);
|
||||||
|
console.error("Error loading quiz session:", cause);
|
||||||
|
set({ isLoading: false, errorCode: code, error: code ? null : "Could not load your quiz" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
start: async () => {
|
||||||
|
const myGeneration = get().generation;
|
||||||
|
set({ isStarting: true, error: null, errorCode: null });
|
||||||
|
try {
|
||||||
|
const { session } = await quizApi.startSession();
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
set({ session, result: null, localAnswers: {}, isStarting: false });
|
||||||
|
Events.quizStarted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
const code = getQuizErrorCode(cause);
|
||||||
|
console.error("Error starting quiz:", cause);
|
||||||
|
set({ isStarting: false, errorCode: code, error: code ? null : "Could not start the quiz" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelfAnswer: (questionId, index) => {
|
||||||
|
set((state) => ({
|
||||||
|
localAnswers: {
|
||||||
|
...state.localAnswers,
|
||||||
|
[questionId]: { ...state.localAnswers[questionId], self_index: index },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setGuessAnswer: (questionId, index) => {
|
||||||
|
set((state) => ({
|
||||||
|
localAnswers: {
|
||||||
|
...state.localAnswers,
|
||||||
|
[questionId]: { ...state.localAnswers[questionId], guess_index: index },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
submit: async () => {
|
||||||
|
const state = get();
|
||||||
|
const session = state.session;
|
||||||
|
if (!session) return false;
|
||||||
|
|
||||||
|
const answers: QuizAnswer[] = session.questions.map((question) => {
|
||||||
|
const local = state.localAnswers[question.id] ?? {};
|
||||||
|
return {
|
||||||
|
question_id: question.id,
|
||||||
|
self_index: local.self_index ?? -1,
|
||||||
|
guess_index: local.guess_index ?? -1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const myGeneration = state.generation;
|
||||||
|
set({ isSubmitting: true, error: null, errorCode: null });
|
||||||
|
try {
|
||||||
|
const { session: updated } = await quizApi.submitAnswers(session.id, answers);
|
||||||
|
if (get().generation !== myGeneration) return false;
|
||||||
|
set({ session: updated, isSubmitting: false, localAnswers: {} });
|
||||||
|
if (updated.score_percent !== null) {
|
||||||
|
Events.quizCompleted(updated.score_percent);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
if (get().generation !== myGeneration) return false;
|
||||||
|
const code = getQuizErrorCode(cause);
|
||||||
|
console.error("Error submitting quiz answers:", cause);
|
||||||
|
set({ isSubmitting: false, errorCode: code, error: code ? null : "Could not submit your answers" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadResult: async () => {
|
||||||
|
const session = get().session;
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const myGeneration = get().generation;
|
||||||
|
set({ isLoadingResult: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await quizApi.getResult(session.id);
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
set({ result, isLoadingResult: false });
|
||||||
|
} catch (cause) {
|
||||||
|
if (get().generation !== myGeneration) return;
|
||||||
|
const code = getQuizErrorCode(cause);
|
||||||
|
console.error("Error loading quiz result:", cause);
|
||||||
|
set({ isLoadingResult: false, errorCode: code, error: code ? null : "Could not load your results" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Clears the in-memory quiz state without bumping the generation, e.g. after starting a new quiz. */
|
||||||
|
reset: () => {
|
||||||
|
set({ session: null, result: null, localAnswers: {}, error: null, errorCode: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQuiz: () => {
|
||||||
|
set((state) => ({
|
||||||
|
session: null,
|
||||||
|
result: null,
|
||||||
|
localAnswers: {},
|
||||||
|
isLoading: false,
|
||||||
|
isStarting: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
isLoadingResult: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: null,
|
||||||
|
// Invalidates any request already in flight: its generation check will
|
||||||
|
// fail and it will not be able to write into the next account's store.
|
||||||
|
generation: state.generation + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>43</string>
|
<string>45</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.5</string>
|
<string>1.0.6</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
+10
-12
@@ -12,15 +12,12 @@ ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
|||||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies from the committed monorepo lockfile.
|
||||||
COPY package*.json ./
|
COPY . .
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy source code
|
# Build the web workspace from the repository root.
|
||||||
COPY . .
|
RUN npm run build -w @sauci/web
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
@@ -37,10 +34,11 @@ RUN apk add --no-cache curl
|
|||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy built assets from builder
|
# Next preserves the workspace path in standalone output. Keep that layout so
|
||||||
COPY --from=builder /app/public ./public
|
# the generated server can resolve its .next directory and public assets.
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
@@ -49,4 +47,4 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "apps/web/server.js"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const pagePath = new URL('./page.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('/delete-account public deletion resource', () => {
|
||||||
|
it('provides an accessible request route and the required deletion details', async () => {
|
||||||
|
const page = await readFile(pagePath, 'utf8')
|
||||||
|
|
||||||
|
expect(page).toContain('Delete your Sauci account')
|
||||||
|
expect(page).toContain('mailto:privacy@sauci.app')
|
||||||
|
expect(page).toContain('without reinstalling the app')
|
||||||
|
expect(page).toContain('Open Sauci and go to your Profile.')
|
||||||
|
expect(page).toContain('Choose Delete Account and follow the confirmation steps.')
|
||||||
|
expect(page).toContain('within 30 days')
|
||||||
|
expect(page).toContain('required for legal purposes')
|
||||||
|
expect(page).toContain('relationship data, question responses, match data, chat messages and photos')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Delete your Sauci account',
|
||||||
|
description: 'How to delete your Sauci account and associated data.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteAccountPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||||
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Back to Home</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Delete your Sauci account</h1>
|
||||||
|
<p className="text-white/70 leading-relaxed text-lg">
|
||||||
|
You can request deletion of your Sauci account and associated data without reinstalling the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="glass p-6 my-8">
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-3">Request deletion by email</h2>
|
||||||
|
<p className="text-white/70 leading-relaxed mb-4">
|
||||||
|
Email us from the address associated with your Sauci account and ask us to delete your account.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:privacy@sauci.app?subject=Sauci%20account%20deletion%20request"
|
||||||
|
className="inline-flex rounded-lg bg-primary px-5 py-3 font-semibold text-white hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Request account deletion
|
||||||
|
</a>
|
||||||
|
<p className="text-white/60 mt-4">
|
||||||
|
Or email <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-invert prose-lg max-w-none space-y-8">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">Delete your account in the Sauci app</h2>
|
||||||
|
<ol className="list-decimal pl-6 text-white/70 space-y-2">
|
||||||
|
<li>Open Sauci and go to your Profile.</li>
|
||||||
|
<li>Select Account.</li>
|
||||||
|
<li>Choose Delete Account and follow the confirmation steps.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">What deletion covers</h2>
|
||||||
|
<p className="text-white/70 leading-relaxed">
|
||||||
|
Deleting an account permanently removes the account and profile, relationship data, question responses, match data, chat messages and photos, encryption keys, and other cloud-stored data associated with that account.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">Timing and limited retention</h2>
|
||||||
|
<p className="text-white/70 leading-relaxed">
|
||||||
|
We will delete or anonymize your personal information within 30 days. We may retain information where required for legal purposes.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">Need help?</h2>
|
||||||
|
<p className="text-white/70 leading-relaxed">
|
||||||
|
For questions about deletion or your data, contact <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="py-8 px-6 border-t border-white/5">
|
||||||
|
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const componentPath = new URL('./JoinClient.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('/join/[code] JoinClient', () => {
|
||||||
|
it('validates the code shape client-side without calling a private API', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("isValidInviteCode(code)")
|
||||||
|
expect(source).not.toMatch(/\/public\/v1\//)
|
||||||
|
expect(source).not.toMatch(/fetch\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attempts the app scheme link and shows the raw code fallback', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('buildInviteSchemeUrl')
|
||||||
|
expect(source).toContain('invite-code-display')
|
||||||
|
expect(source).toContain('Copy code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies only the raw code, not a share message', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('navigator.clipboard.writeText(normalizedCode)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to both app stores', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('APP_STORE_URL')
|
||||||
|
expect(source).toContain('PLAY_STORE_URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('instruments the funnel with PostHog page view, copy, and store-click events', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_viewed'")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_code_copied')")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a clear fallback message for a malformed invite code', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('Invite link not recognized')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Check, Copy, Heart } from 'lucide-react'
|
||||||
|
import { usePostHog } from 'posthog-js/react'
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
||||||
|
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
|
||||||
|
|
||||||
|
export function JoinClient({ code }: { code: string }) {
|
||||||
|
const posthog = usePostHog()
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const hasAttemptedOpen = useRef(false)
|
||||||
|
|
||||||
|
const isValid = isValidInviteCode(code)
|
||||||
|
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code])
|
||||||
|
|
||||||
|
// Track the page view once. Fires regardless of code validity so we can
|
||||||
|
// see broken/expired links in the funnel too.
|
||||||
|
useEffect(() => {
|
||||||
|
posthog?.capture('join_page_viewed', { code_valid: isValid })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Attempt to open the app via the custom scheme once. If the app is
|
||||||
|
// installed and registered for the scheme, this hands off to it; if not,
|
||||||
|
// nothing visible happens and the fallback UI below remains in view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValid || hasAttemptedOpen.current) return
|
||||||
|
hasAttemptedOpen.current = true
|
||||||
|
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
||||||
|
}, [isValid, normalizedCode])
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(normalizedCode)
|
||||||
|
setCopied(true)
|
||||||
|
posthog?.capture('join_page_code_copied')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy invite code:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStoreClick = (store: 'app_store' | 'play_store') => {
|
||||||
|
posthog?.capture('join_page_store_button_clicked', { store })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
||||||
|
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
|
||||||
|
<p className="text-white/60 mb-8">
|
||||||
|
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn-primary inline-block">
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Back to Home</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
|
||||||
|
<Heart className="w-10 h-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to Sauci</h1>
|
||||||
|
<p className="text-white/60 mb-10">
|
||||||
|
Open the app to pair up instantly. If it doesn't open automatically, copy your code below and
|
||||||
|
enter it after you install Sauci.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="glass-light p-6 sm:p-8 mb-8">
|
||||||
|
<p className="text-white/60 text-sm mb-3">Your invite code</p>
|
||||||
|
<p
|
||||||
|
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||||
|
data-testid="invite-code-display"
|
||||||
|
>
|
||||||
|
{normalizedCode}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
Copy code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/40 text-sm mb-6">Don't have Sauci yet? Get the app first:</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href={APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => handleStoreClick('app_store')}
|
||||||
|
className="btn-primary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Download on the App Store
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={PLAY_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => handleStoreClick('play_store')}
|
||||||
|
className="btn-secondary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Get it on Google Play
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
||||||
|
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { JoinClient } from './JoinClient'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Join your partner on Sauci',
|
||||||
|
description: 'Open Sauci and pair up instantly with your invite code.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JoinPage({ params }: { params: { code: string } }) {
|
||||||
|
return <JoinClient code={params.code} />
|
||||||
|
}
|
||||||
@@ -132,6 +132,9 @@ export default function PrivacyPolicy() {
|
|||||||
<p className="text-white/70 leading-relaxed mt-4">
|
<p className="text-white/70 leading-relaxed mt-4">
|
||||||
To exercise these rights, please contact us at <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
|
To exercise these rights, please contact us at <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-white/70 leading-relaxed mt-4">
|
||||||
|
For account deletion instructions and an email deletion request, visit our <Link href="/delete-account" className="text-primary hover:underline">account deletion page</Link>.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -176,6 +179,7 @@ export default function PrivacyPolicy() {
|
|||||||
<span className="text-xl font-bold gradient-text">Sauci</span>
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
<div className="flex gap-6 text-sm text-white/40">
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
|
||||||
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export default function RedeemPage() {
|
|||||||
<span className="text-xl font-bold gradient-text">Sauci</span>
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
<div className="flex gap-6 text-sm text-white/40">
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
|
||||||
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export default function TermsAndConditions() {
|
|||||||
<span className="text-xl font-bold gradient-text">Sauci</span>
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
<div className="flex gap-6 text-sm text-white/40">
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
|
||||||
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export default function Footer() {
|
|||||||
<Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm">
|
<Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/delete-account" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||||
|
Delete Account
|
||||||
|
</Link>
|
||||||
<Link href="/terms" className="text-white/60 hover:text-white transition-colors text-sm">
|
<Link href="/terms" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||||
Terms & Conditions
|
Terms & Conditions
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ../..
|
||||||
dockerfile: Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// App store and deep-link destinations used by the invite join page
|
||||||
|
// (apps/web/app/join/[code]/page.tsx). These are the real, live listing URLs
|
||||||
|
// already used by components/Hero.tsx, components/CTA.tsx,
|
||||||
|
// components/Navbar.tsx, and components/Pricing.tsx.
|
||||||
|
|
||||||
|
export const APP_STORE_URL = 'https://apps.apple.com/gb/app/sauci-couple-games-romance/id6757159885'
|
||||||
|
export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
|
||||||
|
|
||||||
|
export function buildInviteSchemeUrl(code: string): string {
|
||||||
|
return `app.sauci://join?code=${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUniversalUrl(code: string): string {
|
||||||
|
return `https://sauci.app/join/${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from './inviteCode'
|
||||||
|
|
||||||
|
describe('isValidInviteCode', () => {
|
||||||
|
it('accepts 8-character alphanumeric codes', () => {
|
||||||
|
expect(isValidInviteCode('ABCD1234')).toBe(true)
|
||||||
|
expect(isValidInviteCode('abcd1234')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects codes with the wrong length or characters', () => {
|
||||||
|
expect(isValidInviteCode('ABC123')).toBe(false)
|
||||||
|
expect(isValidInviteCode('ABCD12345')).toBe(false)
|
||||||
|
expect(isValidInviteCode('ABCD-123')).toBe(false)
|
||||||
|
expect(isValidInviteCode('')).toBe(false)
|
||||||
|
expect(isValidInviteCode(null)).toBe(false)
|
||||||
|
expect(isValidInviteCode(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeInviteCode', () => {
|
||||||
|
it('trims and uppercases', () => {
|
||||||
|
expect(normalizeInviteCode(' abcd1234 ')).toBe('ABCD1234')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Shared invite code shape validation for the web join page. Mirrors the
|
||||||
|
// mobile app's invite code shape (apps/mobile/src/lib/inviteLink.ts) and the
|
||||||
|
// standalone API's invite_code column. This module only validates the shape
|
||||||
|
// client-side; it never calls a private API.
|
||||||
|
|
||||||
|
const INVITE_CODE_PATTERN = /^[A-Za-z0-9]{8}$/
|
||||||
|
|
||||||
|
export function isValidInviteCode(code: string | null | undefined): boolean {
|
||||||
|
if (!code) return false
|
||||||
|
return INVITE_CODE_PATTERN.test(code.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInviteCode(code: string): string {
|
||||||
|
return code.trim().toUpperCase()
|
||||||
|
}
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
// No file extension by design; iOS requires this exact path and
|
||||||
|
// rejects a response that isn't served as application/json.
|
||||||
|
source: '/.well-known/apple-app-site-association',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Type', value: 'application/json' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/assetlinks.json',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Type', value: 'application/json' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"applinks": {
|
||||||
|
"apps": [],
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"appID": "BXVPBATDZY.com.sauci.app",
|
||||||
|
"paths": ["/join/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"65:2F:E7:60:3F:2E:D2:54:8B:4A:EF:0E:F1:AC:35:3E:DD:13:13:EF:35:E7:90:A7:01:1F:89:0F:4F:71:E2:21"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const aasaPath = new URL('./apple-app-site-association', import.meta.url)
|
||||||
|
const assetlinksPath = new URL('./assetlinks.json', import.meta.url)
|
||||||
|
|
||||||
|
describe('apple-app-site-association', () => {
|
||||||
|
it('is valid JSON that covers /join/*', async () => {
|
||||||
|
const raw = await readFile(aasaPath, 'utf8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
|
||||||
|
expect(data.applinks.details).toHaveLength(1)
|
||||||
|
expect(data.applinks.details[0].paths).toContain('/join/*')
|
||||||
|
// com.sauci.app matches apps/mobile/app.json ios.bundleIdentifier.
|
||||||
|
expect(data.applinks.details[0].appID).toMatch(/\.com\.sauci\.app$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('assetlinks.json', () => {
|
||||||
|
it('is valid JSON that targets the Android package', async () => {
|
||||||
|
const raw = await readFile(assetlinksPath, 'utf8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
|
||||||
|
expect(data[0].target.package_name).toBe('com.sauci.app')
|
||||||
|
expect(data[0].target.namespace).toBe('android_app')
|
||||||
|
// Must be the Play App Signing key certificate fingerprint (Play Console >
|
||||||
|
// Test and release > Setup > App signing), colon-separated uppercase hex.
|
||||||
|
expect(data[0].target.sha256_cert_fingerprints[0]).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
+30
-9
@@ -1,9 +1,17 @@
|
|||||||
# Agent routing and effort
|
# Agent routing and effort
|
||||||
|
|
||||||
The primary agent owns scope, acceptance criteria, task decomposition, and the
|
Sol is the primary agent and owns scope, risk classification, acceptance
|
||||||
final answer. Use one implementation agent when a bounded handoff will keep noisy
|
criteria, task decomposition, progress steering, final diff and evidence review,
|
||||||
execution out of the primary context. Do not pay the coordination cost for a
|
and the final answer. Every substantive repository task must delegate to a
|
||||||
trivial one-file edit. Add other agents only for separable work or a risk trigger.
|
project agent. Each substantive implementation must delegate one coherent
|
||||||
|
execution unit to the Terra `implementation` agent. That handoff normally
|
||||||
|
combines scoped discovery, implementation, focused tests, and iteration in one
|
||||||
|
continuous task. Add a `repo_scout`, reviewer, test writer, or verifier only for
|
||||||
|
separable work or the triggers below. The primary reviews the returned diff and
|
||||||
|
evidence, but does not repeat the implementation agent's detailed exploration or
|
||||||
|
checks without a concrete reason. Skip delegation only for trivial conversation,
|
||||||
|
a single obvious lookup, or a mechanical one-line edit whose coordination cost
|
||||||
|
exceeds the work.
|
||||||
|
|
||||||
## Implementation and integration
|
## Implementation and integration
|
||||||
|
|
||||||
@@ -41,10 +49,10 @@ Project Codex defaults live in `.codex/config.toml`; task profiles live in
|
|||||||
|
|
||||||
| Role | Codex model | Default effort | Trigger | Do not use for |
|
| Role | Codex model | Default effort | Trigger | Do not use for |
|
||||||
|---|---|---:|---|---|
|
|---|---|---:|---|---|
|
||||||
| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions |
|
| `repo_scout` | Luna | low | separable read-only ownership, caller, or contract lookup | editing or design decisions |
|
||||||
| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review |
|
| `implementation` | Terra | medium, overridable | one continuous scoped discovery, implementation, test, and iteration unit | broad architecture review |
|
||||||
| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output |
|
| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output |
|
||||||
| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation |
|
| `verifier` | Terra | medium | user-facing, cross-boundary, or high-risk runtime behavior | editing implementation or low-risk package-local changes |
|
||||||
| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes |
|
| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes |
|
||||||
| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
|
| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
|
||||||
| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors |
|
| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors |
|
||||||
@@ -68,13 +76,26 @@ the implementation agent into a premium generalist. Add the matching read-only
|
|||||||
Sol reviewer and keep implementation on Terra unless the user explicitly asks for
|
Sol reviewer and keep implementation on Terra unless the user explicitly asks for
|
||||||
a different model.
|
a different model.
|
||||||
|
|
||||||
|
## Verification routing
|
||||||
|
|
||||||
|
Use a fresh Terra `verifier` after implementation when the change has
|
||||||
|
user-facing runtime behavior, crosses an application, API, data, authentication,
|
||||||
|
or provider boundary, or is otherwise high risk. The verifier independently
|
||||||
|
drives the acceptance criteria and returns evidence only. It is not required for
|
||||||
|
documentation, mechanical configuration, isolated tests, or low-risk
|
||||||
|
package-local changes with focused deterministic checks. A verifier may still be
|
||||||
|
added when independent observation would materially increase confidence.
|
||||||
|
|
||||||
## Handoff contract
|
## Handoff contract
|
||||||
|
|
||||||
Every delegated task includes the objective, owned files or surface, acceptance
|
Every delegated task includes the objective, owned files or surface, acceptance
|
||||||
criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks,
|
criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks,
|
||||||
and the evidence to return. The agent must report changed files, commands and
|
and the evidence to return. The agent must report changed files, commands and
|
||||||
results, assumptions, and blockers. The primary agent independently reviews the
|
results, assumptions, and blockers. The primary independently reviews the final
|
||||||
diff and decides whether acceptance has been met.
|
diff and evidence and decides whether acceptance has been met. It should steer
|
||||||
|
the active implementation agent with targeted feedback rather than restart the
|
||||||
|
same discovery or verification in another agent unless an independent judgment,
|
||||||
|
separable workstream, or the verification-routing trigger requires it.
|
||||||
|
|
||||||
Parallel agents are for independent read-heavy work or non-overlapping ownership.
|
Parallel agents are for independent read-heavy work or non-overlapping ownership.
|
||||||
Avoid concurrent edits to the same files, shared database state, fixed local
|
Avoid concurrent edits to the same files, shared database state, fixed local
|
||||||
|
|||||||
@@ -263,6 +263,55 @@ DELETE delete-relationship
|
|||||||
Both users: Unpaired
|
Both users: Unpaired
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Invite Funnel (Join Links)
|
||||||
|
|
||||||
|
The invite code can also be delivered as a tappable link instead of requiring
|
||||||
|
the recipient to type it in manually.
|
||||||
|
|
||||||
|
**Link forms:**
|
||||||
|
- Universal/App Link: `https://sauci.app/join/{code}` (served by `apps/web`,
|
||||||
|
opens the app directly on a device that has it installed and verified).
|
||||||
|
- Custom scheme: `app.sauci://join?code={code}` (used by the web join page's
|
||||||
|
automatic hand-off attempt, and remains supported for any existing client
|
||||||
|
behavior).
|
||||||
|
|
||||||
|
**Web fallback (`apps/web/app/join/[code]/page.tsx`):** validates the code
|
||||||
|
shape client-side only (8-character alphanumeric; never calls a private API),
|
||||||
|
attempts the custom scheme link, and otherwise shows the code with a copy
|
||||||
|
button plus App Store / Play Store links. Copying only copies the raw code.
|
||||||
|
|
||||||
|
**Association files:** `apps/web/public/.well-known/apple-app-site-association`
|
||||||
|
and `apps/web/public/.well-known/assetlinks.json` authorize `sauci.app` to open
|
||||||
|
the app for `/join/*`. The Apple Team ID (`BXVPBATDZY`, from
|
||||||
|
`apps/mobile/app.json` and `apps/mobile/eas.json`) and bundle/package id
|
||||||
|
(`com.sauci.app`) are real values already used elsewhere in the repo. The
|
||||||
|
SHA-256 fingerprint in `assetlinks.json` is the Play App Signing key
|
||||||
|
certificate fingerprint from the Play Console (Test and release > Setup >
|
||||||
|
App signing); if the app signing key ever rotates, this file must be updated
|
||||||
|
or Android App Links will stop verifying.
|
||||||
|
|
||||||
|
**Mobile link handling:** `apps/mobile/app.json` declares
|
||||||
|
`associatedDomains: ["applinks:sauci.app"]` (iOS) and an intent filter for
|
||||||
|
`https://sauci.app/join/*` (Android), in addition to the existing `app.sauci`
|
||||||
|
scheme. `apps/mobile/app/_layout.tsx` parses incoming URLs
|
||||||
|
(`src/lib/inviteLink.ts`) and either routes a signed-in, onboarded user
|
||||||
|
straight to the pairing screen with the code pre-filled, or stashes the code
|
||||||
|
(`src/lib/pendingInviteCode.ts`) for signed-out/mid-onboarding users. The
|
||||||
|
stashed code is applied once the user reaches the pairing screen or the app's
|
||||||
|
initial route (`apps/mobile/app/index.tsx`) after sign-in/onboarding
|
||||||
|
completes.
|
||||||
|
|
||||||
|
**Deferred clipboard hand-off:** if an unpaired user opens the pairing screen
|
||||||
|
with no code from a link or stash, the app checks the clipboard once per
|
||||||
|
session (`src/lib/clipboardInviteOffer.ts`) for a string matching the invite
|
||||||
|
code shape and offers to use it ("Use code XXXXXXXX from your clipboard?").
|
||||||
|
The code is never applied automatically; the user must accept the offer.
|
||||||
|
|
||||||
|
**Analytics:** `invite_link_opened` (`source`: `universal_link` | `scheme` |
|
||||||
|
`clipboard`), `pairing_code_prefilled` (mobile, Firebase Analytics), and
|
||||||
|
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
||||||
|
(web, PostHog).
|
||||||
|
|
||||||
## Cascade Behavior
|
## Cascade Behavior
|
||||||
|
|
||||||
When a couple is deleted, foreign key cascades clean up:
|
When a couple is deleted, foreign key cascades clean up:
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ described by their environment examples.
|
|||||||
| MCP | Hono | 3002 |
|
| MCP | Hono | 3002 |
|
||||||
| Expo Metro | Expo | 8081 |
|
| Expo Metro | Expo | 8081 |
|
||||||
|
|
||||||
|
If another local project owns one of the application ports, add a different,
|
||||||
|
unique value to the ignored root `.env.local` before running `up`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SAUCI_WEB_PORT=3100
|
||||||
|
SAUCI_ADMIN_PORT=3101
|
||||||
|
SAUCI_MCP_PORT=3102
|
||||||
|
SAUCI_METRO_PORT=8181
|
||||||
|
```
|
||||||
|
|
||||||
|
`SAUCI_POSTGRES_PORT` and `SAUCI_API_PORT` remain available for their existing
|
||||||
|
local overrides. Every configured port must be a unique integer from 1 through
|
||||||
|
65535. Before launching managed services, the launcher checks both IPv4 and IPv6
|
||||||
|
listeners and fails closed with the owning PID, command, and working directory;
|
||||||
|
it never stops another project's process.
|
||||||
|
|
||||||
PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup,
|
PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup,
|
||||||
and stores data in a named Docker volume. The local connection string is:
|
and stores data in a named Docker volume. The local connection string is:
|
||||||
|
|
||||||
@@ -26,28 +42,56 @@ postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
|||||||
Those credentials are for local development only. Deployed environments must
|
Those credentials are for local development only. Deployed environments must
|
||||||
inject unique secrets; they must never consume `infra/backend/.env.example`.
|
inject unique secrets; they must never consume `infra/backend/.env.example`.
|
||||||
|
|
||||||
## First run
|
## Start the stack
|
||||||
|
|
||||||
1. Use Node from `.nvmrc` and run `npm ci`.
|
One command starts the local database, applies migrations and fixtures, and
|
||||||
2. Install Docker Desktop or Rancher Desktop and tmux.
|
launches the application services:
|
||||||
3. Configure the hosted Supabase Auth public URL/key for the mobile application
|
|
||||||
and the hosted Auth issuer/JWKS settings for the API. Do not configure any
|
|
||||||
local command with production credentials. Authenticated mobile E2E requires
|
|
||||||
the designated hosted non-production Auth project and a disposable,
|
|
||||||
onboarding-complete user whose subject has a matching local profile. Pass
|
|
||||||
its credentials as `E2E_EMAIL` and `E2E_PASSWORD` only to the Maestro process;
|
|
||||||
do not store them in an Expo environment file.
|
|
||||||
To exercise MCP tools, also set a local `ADMIN_API_SERVICE_TOKEN`, its
|
|
||||||
fixture `ADMIN_API_SERVICE_USER_ID`, and a separate `SAUCI_MCP_API_KEY`.
|
|
||||||
`scripts/dev-local.sh` passes the service token to MCP as
|
|
||||||
`SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
|
|
||||||
credential.
|
|
||||||
4. Run `scripts/dev-local.sh reset` to recreate only the dedicated local Docker
|
|
||||||
volume, apply API migrations, and create configured fixtures.
|
|
||||||
5. Run `scripts/dev-local.sh up`.
|
|
||||||
|
|
||||||
`up` is idempotent. `down` stops tmux application processes and leaves the
|
```bash
|
||||||
database available. `down --all` also stops PostgreSQL but retains its volume.
|
scripts/dev-local.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
Before the first use, install Docker Desktop or Rancher Desktop, tmux, and the
|
||||||
|
repository dependencies with `npm ci`. The launcher reads `.nvmrc` itself. When
|
||||||
|
the active Node version differs, it loads an installed Homebrew `nvm` and selects
|
||||||
|
the exact version automatically. If that version has not been installed yet, run
|
||||||
|
the command the launcher prints once, then rerun `up`.
|
||||||
|
|
||||||
|
Create the ignored root `.env.local` with the public Auth settings for the
|
||||||
|
designated non-production Supabase project (`itbzhrvlgvdmzbnhzhyx`). Set
|
||||||
|
`SUPABASE_AUTH_URL` (or `EXPO_PUBLIC_SUPABASE_URL`) and
|
||||||
|
`EXPO_PUBLIC_SUPABASE_ANON_KEY`; the launcher uses the URL for the API and both
|
||||||
|
values for Expo. It accepts only this designated hosted project or loopback Auth,
|
||||||
|
and explicitly rejects the production project and every other hosted project.
|
||||||
|
Environment variables exported in the shell take precedence over `.env.local`.
|
||||||
|
The launcher treats `.env.local` as data, never shell code: use unquoted
|
||||||
|
`KEY=VALUE` lines for only the documented launcher keys. Never place production
|
||||||
|
credentials or Auth issuer/JWKS, Auth service-role, RevenueCat, or other provider
|
||||||
|
secrets in `.env.local`; the launcher removes those inherited values from its
|
||||||
|
local API and worker processes. Each `up` replaces its managed tmux windows so
|
||||||
|
stale application processes cannot retain earlier configuration, while leaving
|
||||||
|
unrelated windows intact. Credentials are passed only to the local process that
|
||||||
|
needs them: MCP receives its local API token/key but never `DATABASE_URL`.
|
||||||
|
Before replacing a managed window, the launcher verifies and stops only its
|
||||||
|
current descendant processes. If those descendants do not exit promptly, it
|
||||||
|
leaves that window in place and fails rather than broadening process cleanup.
|
||||||
|
This includes a previously captured descendant that has left the pane lineage:
|
||||||
|
the launcher will not signal it again or replace the window until that captured
|
||||||
|
process identity is gone.
|
||||||
|
|
||||||
|
Authenticated mobile E2E requires a disposable, onboarding-complete user whose
|
||||||
|
subject has a matching local profile. Pass its credentials as `E2E_EMAIL` and
|
||||||
|
`E2E_PASSWORD` only to the Maestro process; do not store them in an Expo
|
||||||
|
environment file. To exercise MCP tools, also set a local
|
||||||
|
`ADMIN_API_SERVICE_TOKEN`, its fixture `ADMIN_API_SERVICE_USER_ID`, and a
|
||||||
|
separate `SAUCI_MCP_API_KEY`. `scripts/dev-local.sh` passes the service token to
|
||||||
|
MCP as `SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
|
||||||
|
credential.
|
||||||
|
|
||||||
|
`up` is idempotent. `down` stops only the launcher's managed tmux application
|
||||||
|
windows and leaves unrelated windows and the database available. `down --all`
|
||||||
|
also stops PostgreSQL but retains its volume, and runs only after managed
|
||||||
|
application cleanup succeeds.
|
||||||
Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis.
|
Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis.
|
||||||
`logs postgres` reads Compose logs; other names read their tmux panes.
|
`logs postgres` reads Compose logs; other names read their tmux panes.
|
||||||
The `worker` pane owns scheduled jobs, notifications, moderation classification,
|
The `worker` pane owns scheduled jobs, notifications, moderation classification,
|
||||||
@@ -61,8 +105,10 @@ The API seed is a no-op unless its documented local fixture identity is set.
|
|||||||
|
|
||||||
The Expo window defaults to Metro with
|
The Expo window defaults to Metro with
|
||||||
`EXPO_PUBLIC_API_URL=http://127.0.0.1:3003`. Use `scripts/dev-local.sh ios` or
|
`EXPO_PUBLIC_API_URL=http://127.0.0.1:3003`. Use `scripts/dev-local.sh ios` or
|
||||||
`android` to launch the native application against the same API. Physical
|
`android` to launch the native application against the same API. The native
|
||||||
devices require a reachable host URL rather than loopback.
|
launcher supplies a UTF-8 locale inside its sanitized environment so CocoaPods
|
||||||
|
does not depend on the calling shell's locale. Physical devices require a
|
||||||
|
reachable host URL rather than loopback.
|
||||||
|
|
||||||
`e2e/maestro/login-screen-smoke.yaml` verifies the native build and Auth shell
|
`e2e/maestro/login-screen-smoke.yaml` verifies the native build and Auth shell
|
||||||
without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated
|
without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Testing strategy](testing/strategy.md)
|
- [Testing strategy](testing/strategy.md)
|
||||||
- [Database migration safety](operations/database-migrations.md)
|
- [Database migration safety](operations/database-migrations.md)
|
||||||
- [Backend deployment and branching](operations/backend-deployments.md)
|
- [Backend deployment and branching](operations/backend-deployments.md)
|
||||||
|
- [Website deployment](operations/web-deployments.md)
|
||||||
- [Agent routing and effort](agents/routing.md)
|
- [Agent routing and effort](agents/routing.md)
|
||||||
- [Learning promotion](agents/learnings.md)
|
- [Learning promotion](agents/learnings.md)
|
||||||
- [Authentication](authentication.md)
|
- [Authentication](authentication.md)
|
||||||
@@ -23,6 +24,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Redemption codes](product/redemption.md)
|
- [Redemption codes](product/redemption.md)
|
||||||
- [Mobile release process](releasing.md)
|
- [Mobile release process](releasing.md)
|
||||||
- [Dares loop](dares-loop.md)
|
- [Dares loop](dares-loop.md)
|
||||||
|
- [Couples quiz](quiz.md)
|
||||||
|
|
||||||
## Feature and implementation references
|
## Feature and implementation references
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Website deployment
|
||||||
|
|
||||||
|
Dokploy deploys the public Sauci website from the `main` branch using
|
||||||
|
`apps/web/docker-compose.prod.yml`. The Compose file builds from the repository
|
||||||
|
root so the Docker build can use the committed workspace lockfile. Its Dockerfile
|
||||||
|
builds `@sauci/web` and runs the Next standalone server from its workspace path.
|
||||||
|
|
||||||
|
## Required configuration
|
||||||
|
|
||||||
|
`NEXT_PUBLIC_API_URL` is required at build time and must be the intended public
|
||||||
|
standalone API URL. `NEXT_PUBLIC_POSTHOG_KEY` is optional. When it is set,
|
||||||
|
`NEXT_PUBLIC_POSTHOG_HOST` defaults to `https://eu.i.posthog.com` unless Dokploy
|
||||||
|
provides a different value. These public values are inlined into the Next build;
|
||||||
|
do not place server credentials in them.
|
||||||
|
|
||||||
|
## Promotion and acceptance
|
||||||
|
|
||||||
|
Promote the verified website commit to `main` before allowing Dokploy to deploy
|
||||||
|
it. After the deployment reports success, verify valid TLS and HTTP 200 for:
|
||||||
|
|
||||||
|
- `https://sauci.app/`
|
||||||
|
- `https://sauci.app/delete-account`
|
||||||
|
|
||||||
|
The second check is required for the Google Play account-deletion resource. A
|
||||||
|
successful Dokploy deployment alone is not sufficient evidence that either URL
|
||||||
|
is publicly reachable.
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
# Couples quiz
|
||||||
|
|
||||||
|
Maintained reference for the shareable "how well do you know each other?"
|
||||||
|
quiz: a couple works through the same set of questions, each partner answers
|
||||||
|
about themselves and guesses about the other, and the pair only sees a score
|
||||||
|
once both have finished.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---|---|
|
||||||
|
| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` |
|
||||||
|
| HTTP contract | `apps/api/src/domains/quiz/routes.ts` |
|
||||||
|
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
|
||||||
|
| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) |
|
||||||
|
|
||||||
|
## Session lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/quiz/sessions ──► active session (10 questions, question order fixed at creation)
|
||||||
|
│
|
||||||
|
├── each partner submits a full answer set (self + guess per question)
|
||||||
|
│
|
||||||
|
└── once BOTH partners have a complete set ──► session completes, score is computed
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one active session exists per couple at a time (`quiz_sessions_one_active_per_couple_idx`,
|
||||||
|
a partial unique index on `couple_id` where `status = 'active'`). Starting a
|
||||||
|
quiz while one is already active returns that same session (`200`) instead of
|
||||||
|
creating a second one; two simultaneous starts race safely because the
|
||||||
|
partial unique index, not application locking, is the source of truth.
|
||||||
|
|
||||||
|
Answers are submitted as one complete batch per call (`quiz_answers`, upsert
|
||||||
|
on `(session_id, user_id, question_id)`). A submission must cover every
|
||||||
|
question in the session exactly once, with every index inside that
|
||||||
|
question's option list, or it is rejected with `400 invalid_answers`. A
|
||||||
|
session that has already completed rejects further submissions with
|
||||||
|
`409 session_completed`.
|
||||||
|
|
||||||
|
## Scoring
|
||||||
|
|
||||||
|
Each question contributes up to two hits: partner A's guess about B counts if
|
||||||
|
it matches B's self-answer, and separately B's guess about A counts if it
|
||||||
|
matches A's self-answer. `score_percent = round(100 * hits / (2 *
|
||||||
|
question_count))`. `computeQuizScore` in `service.ts` is the pure,
|
||||||
|
unit-tested implementation; the repository only assembles the per-question
|
||||||
|
pairs and persists the result.
|
||||||
|
|
||||||
|
## Couple isolation
|
||||||
|
|
||||||
|
Every session and result lookup is scoped to the caller's own couple. A
|
||||||
|
session ID that belongs to another couple returns `404 session_not_found`,
|
||||||
|
never a hint that it exists elsewhere. Starting a quiz requires both a couple
|
||||||
|
(`409 no_couple`) and a paired partner (`409 partner_required`).
|
||||||
|
|
||||||
|
## HTTP contract
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/v1/quiz/sessions` | `201` on a new session, `200` when an active one already exists |
|
||||||
|
| GET | `/v1/quiz/sessions/current` | `{ session: SessionPayload \| null }`, most recent session for the couple |
|
||||||
|
| POST | `/v1/quiz/sessions/:sessionId/answers` | `{ answers: [{ question_id, self_index, guess_index }] }` |
|
||||||
|
| GET | `/v1/quiz/sessions/:sessionId/result` | `409 session_not_completed` before both partners finish |
|
||||||
|
|
||||||
|
`SessionPayload` never includes the partner's raw answers before completion;
|
||||||
|
per-question partner detail (`partner_self_index`, `partner_guess_index`,
|
||||||
|
`partner_guessed_right`) is only exposed by the `/result` endpoint of a
|
||||||
|
completed session.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `apps/api/test/quiz.service.test.ts`: unit tests for `computeQuizScore`
|
||||||
|
- `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping
|
||||||
|
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
|
||||||
|
happy path with a hand-computed score, idempotent session start, couple
|
||||||
|
isolation, incomplete answer set rejection, and result blocked before
|
||||||
|
completion
|
||||||
|
|
||||||
|
## Mobile client
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---|---|
|
||||||
|
| Feature screens, hooks, API client | `apps/mobile/src/features/quiz/` |
|
||||||
|
| Zustand store (generation-counter pattern, cleared on sign-out) | `apps/mobile/src/store/quizStore.ts` |
|
||||||
|
| Discovery entry point | `apps/mobile/src/components/discovery/QuizTile.tsx` |
|
||||||
|
| Route wrapper | `apps/mobile/app/(app)/quiz.tsx` |
|
||||||
|
|
||||||
|
The screen walks not-paired, intro, answering (one question at a time, self
|
||||||
|
then guess), waiting-on-partner, and results states off `useQuizScreen`.
|
||||||
|
Answers are held client-side per question until every question in the
|
||||||
|
session has both a self and a guess index, then submitted as the one batch
|
||||||
|
the API expects; the store never calls the answers endpoint more than once
|
||||||
|
per session. Results render a shareable card (`QuizResultsShareCard` /
|
||||||
|
`QuizShareModal`, following the `SharePreviewModal` capture pattern) carrying
|
||||||
|
only the match score and Sauci branding, never question text or answers, so a
|
||||||
|
screenshot cannot leak either partner's content.
|
||||||
|
|
||||||
|
## Not in this version
|
||||||
|
|
||||||
|
Question authoring tooling in the admin app is out of scope for this change.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Sauci Mobile Release Notes
|
# Sauci Mobile Release Notes
|
||||||
|
|
||||||
|
## v1.0.6 — 30 August 2026
|
||||||
|
_iOS build 44 · Android build 48_
|
||||||
|
|
||||||
|
A little more clarity, momentum, and playfulness for your shared check-ins.
|
||||||
|
|
||||||
|
- **Your turn at a glance** — A private Home prompt now shows when questions from your partner are waiting, without revealing their answers.
|
||||||
|
- **New ways to play** — Browse, send, and respond to Dares together from a dedicated shared space.
|
||||||
|
- **Streaks that fit your day** — Shared streaks now follow your local day, with clearer progress and reminder controls.
|
||||||
|
- **Smoother shared moments** — Leaner syncing keeps questions, matches, chat, and Live Draw feeling current with less unnecessary refreshing.
|
||||||
|
|
||||||
## v1.0.1 (Build 30) - January 2025
|
## v1.0.1 (Build 30) - January 2025
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
+65
-3
@@ -33,6 +33,46 @@ Output files are placed in the current directory by default.
|
|||||||
These scripts create signed artifacts only. They do not upload or submit them.
|
These scripts create signed artifacts only. They do not upload or submit them.
|
||||||
Store mutation always requires a separate, explicitly authorized command.
|
Store mutation always requires a separate, explicitly authorized command.
|
||||||
|
|
||||||
|
## Submit a local artifact with EAS
|
||||||
|
|
||||||
|
The production submission credentials are managed by EAS. Never add App Store
|
||||||
|
Connect `.p8` files, Google service-account JSON files, or local credential paths
|
||||||
|
to the repository or `eas.json`.
|
||||||
|
|
||||||
|
Submit only an independently verified artifact by its exact absolute path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/mobile
|
||||||
|
|
||||||
|
# Uploads an Android internal-track draft; it does not roll out the release.
|
||||||
|
eas submit --platform android --profile production --path /absolute/path/to/sauci.aab --wait
|
||||||
|
|
||||||
|
# Uploads to App Store Connect/TestFlight; it does not submit for App Review.
|
||||||
|
eas submit --platform ios --profile production --path /absolute/path/to/sauci.ipa --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Agents must not upload immediately after building, whether they use EAS,
|
||||||
|
Fastlane, Xcode Organizer, or a store console. Once the artifact is verified, the
|
||||||
|
agent must show the user the platform, exact path, bundle/package identifier,
|
||||||
|
version, build number, signing identity, SHA-256 digest, and destination, then ask
|
||||||
|
for a fresh confirmation to upload it. This confirmation authorizes one upload
|
||||||
|
of that exact artifact to that exact destination, even if the earlier request
|
||||||
|
already said to build, release, upload, or submit.
|
||||||
|
|
||||||
|
Before an iOS confirmation, inspect App Store Connect for automatic TestFlight
|
||||||
|
distribution. If upload will add the build to existing groups or testers, name
|
||||||
|
them in the confirmation and include that effect in the authorization. If the
|
||||||
|
effect cannot be established, stop. Changing automatic-distribution settings is
|
||||||
|
a separate action and requires its own authorization.
|
||||||
|
|
||||||
|
Do not use `--latest` for a local release. Before upload, verify the artifact's
|
||||||
|
package or bundle identifier, public version, build number, signature, and
|
||||||
|
SHA-256 digest. After upload, read back the same identity and version from Google
|
||||||
|
Play Console or App Store Connect. Public rollout, App Review submission, and
|
||||||
|
additional TestFlight group distribution remain separate, explicitly authorized
|
||||||
|
actions. If an upload command returns an ambiguous result, inspect EAS and the
|
||||||
|
store first; do not retry unless the user gives fresh authorization.
|
||||||
|
|
||||||
### Source integrity
|
### Source integrity
|
||||||
|
|
||||||
Sauci commits its native `ios/` and `android/` projects. Start a release from a
|
Sauci commits its native `ios/` and `android/` projects. Start a release from a
|
||||||
@@ -46,6 +86,15 @@ may change: `app.json`, the platform native version file, and the iOS widget
|
|||||||
project rewrite means the source was regenerated and the artifact must be rebuilt
|
project rewrite means the source was regenerated and the artifact must be rebuilt
|
||||||
from the clean tracked sources.
|
from the clean tracked sources.
|
||||||
|
|
||||||
|
### Android media permission policy
|
||||||
|
|
||||||
|
Sauci uses the Android system photo picker for user-selected images and videos.
|
||||||
|
Android release manifests must not contain `READ_MEDIA_IMAGES` or
|
||||||
|
`READ_MEDIA_VIDEO`. Gallery save flows request add-only access and must not add
|
||||||
|
broad read access. After any Expo, media-library, image-picker, or native Android
|
||||||
|
change, run `:app:processReleaseMainManifest` and inspect the merged release
|
||||||
|
manifest before building an artifact for Google Play.
|
||||||
|
|
||||||
### Building an APK (for Testing)
|
### Building an APK (for Testing)
|
||||||
|
|
||||||
The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing:
|
The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing:
|
||||||
@@ -196,12 +245,19 @@ android/app/build/outputs/bundle/release/app-release.aab
|
|||||||
|
|
||||||
### Step 5: Upload to Play Store
|
### Step 5: Upload to Play Store
|
||||||
|
|
||||||
|
Apply the upload authorization gate above before opening or changing the release.
|
||||||
|
Uploading the AAB and starting any rollout are separate actions; ask again before
|
||||||
|
the rollout.
|
||||||
|
|
||||||
1. Go to [Google Play Console](https://play.google.com/console)
|
1. Go to [Google Play Console](https://play.google.com/console)
|
||||||
2. Select **Sauci** app
|
2. Select **Sauci** app
|
||||||
3. Go to **Production** (or testing track)
|
3. Go to **Internal testing**, unless the user authorized a different exact track
|
||||||
4. **Create new release**
|
4. **Create new release**
|
||||||
5. Upload the `.aab` file
|
5. Upload the `.aab` file
|
||||||
6. Add release notes and submit
|
6. Add release notes, save the release as a draft, and read it back
|
||||||
|
|
||||||
|
Do not send the draft for review or start a rollout without the separate fresh
|
||||||
|
authorization described above.
|
||||||
|
|
||||||
### Verifying Keystore Fingerprint
|
### Verifying Keystore Fingerprint
|
||||||
|
|
||||||
@@ -211,7 +267,7 @@ To verify you're using the correct keystore:
|
|||||||
keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
|
keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43`
|
Expected EAS production upload-key SHA1: `F0:60:26:C7:54:B0:46:F2:80:FE:AC:43:8A:69:10:BD:9A:62:B4:59`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,6 +295,9 @@ cd ..
|
|||||||
|
|
||||||
### Step 4: Upload to App Store
|
### Step 4: Upload to App Store
|
||||||
|
|
||||||
|
Apply the upload authorization gate above before using Xcode Organizer. Include
|
||||||
|
any verified automatic TestFlight distribution in the confirmation.
|
||||||
|
|
||||||
1. In Xcode Organizer (Window → Organizer), select the archive
|
1. In Xcode Organizer (Window → Organizer), select the archive
|
||||||
2. Click **Distribute App**
|
2. Click **Distribute App**
|
||||||
3. Select **App Store Connect** → **Upload**
|
3. Select **App Store Connect** → **Upload**
|
||||||
@@ -246,6 +305,9 @@ cd ..
|
|||||||
|
|
||||||
### Step 5: Submit in App Store Connect
|
### Step 5: Submit in App Store Connect
|
||||||
|
|
||||||
|
Submitting for App Review is not authorized by the upload confirmation. Ask the
|
||||||
|
user for fresh authorization immediately before this step.
|
||||||
|
|
||||||
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||||
2. Select **Sauci**
|
2. Select **Sauci**
|
||||||
3. Create a new version or select the uploaded build
|
3. Create a new version or select the uploaded build
|
||||||
|
|||||||
+409
-37
@@ -5,8 +5,98 @@ SESSION="sauci-dev"
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
COMPOSE_FILE="$ROOT/infra/backend/compose.yaml"
|
COMPOSE_FILE="$ROOT/infra/backend/compose.yaml"
|
||||||
COMPOSE_PROJECT="sauci-local"
|
COMPOSE_PROJECT="sauci-local"
|
||||||
|
DESCENDANT_SHUTDOWN_ATTEMPTS=30
|
||||||
|
DESCENDANT_SHUTDOWN_DELAY_SECONDS=0.1
|
||||||
|
|
||||||
|
load_env_local() {
|
||||||
|
local env_file="$ROOT/.env.local" line line_number=0 key value seen_keys=' '
|
||||||
|
local safe_value_pattern='^[A-Za-z0-9._:/?&=+%@,#-]+$'
|
||||||
|
[ -f "$env_file" ] || return 0
|
||||||
|
|
||||||
|
# This is deliberately a data parser, not `source`: .env.local is ignored
|
||||||
|
# developer input and must never execute in the launcher's shell.
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
line_number=$((line_number + 1))
|
||||||
|
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||||
|
if [[ ! "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]]; then
|
||||||
|
printf '✗ %s:%s must be KEY=VALUE with no quoting or shell syntax.\n' "$env_file" "$line_number" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
value="${BASH_REMATCH[2]}"
|
||||||
|
case " $key " in
|
||||||
|
" SAUCI_POSTGRES_PORT "|" SAUCI_API_PORT "|" SAUCI_WEB_PORT "|" SAUCI_ADMIN_PORT "|\
|
||||||
|
" SAUCI_MCP_PORT "|" SAUCI_METRO_PORT "|" SAUCI_MOBILE_API_URL "|\
|
||||||
|
" SUPABASE_AUTH_URL "|" EXPO_PUBLIC_SUPABASE_URL "|" EXPO_PUBLIC_SUPABASE_ANON_KEY "|\
|
||||||
|
" ADMIN_API_SERVICE_USER_ID "|" ADMIN_API_SERVICE_TOKEN "|" SAUCI_MCP_API_KEY ") ;;
|
||||||
|
*)
|
||||||
|
printf '✗ %s:%s contains unsupported launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$seen_keys" in
|
||||||
|
*" $key "*)
|
||||||
|
printf '✗ %s:%s duplicates launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ -z "$value" || ! "$value" =~ $safe_value_pattern ]]; then
|
||||||
|
printf '✗ %s:%s contains an unsafe or empty value.\n' "$env_file" "$line_number" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Explicit caller exports are authoritative over ignored local defaults.
|
||||||
|
printenv "$key" >/dev/null 2>&1 || export "$key=$value"
|
||||||
|
seen_keys+="$key "
|
||||||
|
done <"$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_nvm() {
|
||||||
|
command -v nvm >/dev/null 2>&1 && return
|
||||||
|
|
||||||
|
local nvm_dir="${NVM_DIR:-$HOME/.nvm}" brew_nvm_prefix
|
||||||
|
local -a candidates=("$nvm_dir/nvm.sh")
|
||||||
|
if command -v brew >/dev/null 2>&1; then
|
||||||
|
brew_nvm_prefix="$(brew --prefix nvm 2>/dev/null || true)"
|
||||||
|
[ -n "$brew_nvm_prefix" ] && candidates+=("$brew_nvm_prefix/nvm.sh")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local candidate
|
||||||
|
for candidate in "${candidates[@]}"; do
|
||||||
|
if [ -s "$candidate" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$candidate"
|
||||||
|
command -v nvm >/dev/null 2>&1 && return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
activate_required_node() {
|
||||||
|
local required_node current_node
|
||||||
|
required_node="$(tr -d '[:space:]' <"$ROOT/.nvmrc")"
|
||||||
|
[ -n "$required_node" ] || die ".nvmrc must specify a Node version."
|
||||||
|
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
current_node="$(node -p 'process.versions.node')"
|
||||||
|
[ "$current_node" = "$required_node" ] && return
|
||||||
|
fi
|
||||||
|
|
||||||
|
load_nvm || die "Node $required_node is required. Install Homebrew nvm, then run: nvm install $required_node"
|
||||||
|
nvm use --silent "$required_node" >/dev/null 2>&1 || die "Node $required_node is required. Install it once with: nvm install $required_node"
|
||||||
|
current_node="$(node -p 'process.versions.node')"
|
||||||
|
[ "$current_node" = "$required_node" ] || die "Node $required_node is required; nvm selected $current_node instead."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read the ignored developer configuration before computing launch settings.
|
||||||
|
# Explicit environment variables take precedence over .env.local values.
|
||||||
|
load_env_local
|
||||||
|
|
||||||
POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}"
|
POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}"
|
||||||
API_PORT="${SAUCI_API_PORT:-3003}"
|
API_PORT="${SAUCI_API_PORT:-3003}"
|
||||||
|
WEB_PORT="${SAUCI_WEB_PORT:-3000}"
|
||||||
|
ADMIN_PORT="${SAUCI_ADMIN_PORT:-3001}"
|
||||||
|
MCP_PORT="${SAUCI_MCP_PORT:-3002}"
|
||||||
|
METRO_PORT="${SAUCI_METRO_PORT:-8081}"
|
||||||
LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci"
|
LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci"
|
||||||
LOCAL_API_URL="http://127.0.0.1:${API_PORT}"
|
LOCAL_API_URL="http://127.0.0.1:${API_PORT}"
|
||||||
MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}"
|
MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}"
|
||||||
@@ -17,21 +107,21 @@ LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service-
|
|||||||
LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
|
LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
|
||||||
|
|
||||||
SERVERS=(
|
SERVERS=(
|
||||||
"api|env PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL SUPABASE_AUTH_URL=$AUTH_URL ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
|
"api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
|
||||||
"worker|env DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
|
"worker|DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
|
||||||
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port 3000"
|
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port $WEB_PORT"
|
||||||
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001"
|
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port $ADMIN_PORT"
|
||||||
"mcp|env PORT=3002 SAUCI_ADMIN_API_URL=$LOCAL_API_URL SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY npm run dev -w sauci-mcp-server"
|
"mcp|env PORT=$MCP_PORT npm run dev -w sauci-mcp-server"
|
||||||
"mobile|env EXPO_PUBLIC_API_URL=$MOBILE_API_URL EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY npm run dev -w @sauci/mobile -- --port 8081 --localhost"
|
"mobile|npm run dev -w @sauci/mobile -- --port $METRO_PORT --localhost"
|
||||||
)
|
)
|
||||||
|
|
||||||
PORTS=(
|
PORTS=(
|
||||||
"postgres:$POSTGRES_PORT"
|
"postgres:$POSTGRES_PORT"
|
||||||
"api:$API_PORT"
|
"api:$API_PORT"
|
||||||
"web:3000"
|
"web:$WEB_PORT"
|
||||||
"admin:3001"
|
"admin:$ADMIN_PORT"
|
||||||
"mcp:3002"
|
"mcp:$MCP_PORT"
|
||||||
"metro:8081"
|
"metro:$METRO_PORT"
|
||||||
)
|
)
|
||||||
|
|
||||||
c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m'
|
c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m'
|
||||||
@@ -46,15 +136,21 @@ compose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
preflight() {
|
preflight() {
|
||||||
|
activate_required_node
|
||||||
|
if ! node - "$POSTGRES_PORT" "$API_PORT" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT" "$METRO_PORT" <<'NODE'
|
||||||
|
const ports = process.argv.slice(2);
|
||||||
|
const valid = ports.every((port) => /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535);
|
||||||
|
if (!valid || new Set(ports.map(Number)).size !== ports.length) process.exitCode = 1;
|
||||||
|
NODE
|
||||||
|
then
|
||||||
|
die "Configured ports must be unique integers from 1 to 65535."
|
||||||
|
fi
|
||||||
command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux"
|
command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux"
|
||||||
command -v npm >/dev/null 2>&1 || die "npm not found. Install Node $(cat "$ROOT/.nvmrc")."
|
command -v npm >/dev/null 2>&1 || die "npm not found after activating Node $(cat "$ROOT/.nvmrc")."
|
||||||
command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found."
|
command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found."
|
||||||
docker info >/dev/null 2>&1 || die "Container runtime is not running."
|
docker info >/dev/null 2>&1 || die "Container runtime is not running."
|
||||||
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required."
|
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required."
|
||||||
[ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci"
|
[ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci"
|
||||||
local node_major
|
|
||||||
node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
|
|
||||||
[ "$node_major" = "20" ] || die "Node 20 is required. Activate .nvmrc before starting the stack."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_local_database() {
|
assert_local_database() {
|
||||||
@@ -65,10 +161,212 @@ assert_local_database() {
|
|||||||
[ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320."
|
[ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320."
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_local_auth() {
|
assert_allowed_auth() {
|
||||||
case "$AUTH_URL" in
|
local verdict
|
||||||
http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) ;;
|
verdict="$(node - "$AUTH_URL" <<'NODE'
|
||||||
*) die "Refusing local development against non-local Supabase Auth: $AUTH_URL" ;;
|
const raw = process.argv[2];
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) throw new Error('shape');
|
||||||
|
const isCanonicalRoot = raw === url.origin || raw === `${url.origin}/`;
|
||||||
|
const isLoopback = isCanonicalRoot && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
||||||
|
const isNonProduction = isCanonicalRoot && url.protocol === 'https:' && url.origin === 'https://itbzhrvlgvdmzbnhzhyx.supabase.co';
|
||||||
|
if (isLoopback || isNonProduction) process.stdout.write('allowed');
|
||||||
|
else if (url.hostname === 'ckjcrkjpmhqhiucifukx.supabase.co') process.stdout.write('production');
|
||||||
|
else process.stdout.write('unapproved');
|
||||||
|
} catch {
|
||||||
|
process.stdout.write('invalid');
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
)"
|
||||||
|
case "$verdict" in
|
||||||
|
allowed) ;;
|
||||||
|
production) die "Refusing local development against the production Supabase Auth project." ;;
|
||||||
|
invalid) die "SUPABASE_AUTH_URL must be a valid root Auth URL without credentials, a path, query, or fragment." ;;
|
||||||
|
*) die "Refusing local development against an unapproved Supabase Auth URL." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_session() {
|
||||||
|
BOOTSTRAP_CREATED=false
|
||||||
|
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
|
||||||
|
BOOTSTRAP_CREATED=true
|
||||||
|
elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -qx '_bootstrap'; then
|
||||||
|
tmux new-window -d -t "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
|
||||||
|
BOOTSTRAP_CREATED=true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_managed_windows() {
|
||||||
|
local name failed=false
|
||||||
|
for name in api worker web admin mcp mobile; do
|
||||||
|
stop_managed_window "$name" || failed=true
|
||||||
|
done
|
||||||
|
[ "$failed" = false ]
|
||||||
|
}
|
||||||
|
|
||||||
|
is_numeric_pid() {
|
||||||
|
[[ "$1" =~ ^[1-9][0-9]*$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
pane_pid_for_window() {
|
||||||
|
local name="$1" pane_pid
|
||||||
|
pane_pid="$(tmux display-message -p -t "$SESSION:$name" '#{pane_pid}' 2>/dev/null || true)"
|
||||||
|
is_numeric_pid "$pane_pid" && printf '%s\n' "$pane_pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_start_time() {
|
||||||
|
local pid="$1" start_time
|
||||||
|
is_numeric_pid "$pid" || return 1
|
||||||
|
start_time="$(ps -p "$pid" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||||
|
[ -n "$start_time" ] && printf '%s\n' "$start_time"
|
||||||
|
}
|
||||||
|
|
||||||
|
has_pane_ancestry() {
|
||||||
|
local pid="$1" pane_pid="$2" parent_pid hops=0
|
||||||
|
is_numeric_pid "$pid" && is_numeric_pid "$pane_pid" || return 1
|
||||||
|
while [ "$hops" -lt 64 ]; do
|
||||||
|
parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d '[:space:]')"
|
||||||
|
is_numeric_pid "$parent_pid" || return 1
|
||||||
|
[ "$parent_pid" = "$pane_pid" ] && return 0
|
||||||
|
[ "$parent_pid" -gt 1 ] || return 1
|
||||||
|
pid="$parent_pid"
|
||||||
|
hops=$((hops + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_descendants() {
|
||||||
|
local parent_pid="$1" child_pid
|
||||||
|
is_numeric_pid "$parent_pid" || return 0
|
||||||
|
while IFS= read -r child_pid; do
|
||||||
|
is_numeric_pid "$child_pid" || continue
|
||||||
|
capture_descendants "$child_pid"
|
||||||
|
local start_time
|
||||||
|
start_time="$(process_start_time "$child_pid" || true)"
|
||||||
|
[ -n "$start_time" ] || continue
|
||||||
|
MANAGED_DESCENDANT_PIDS+=("$child_pid")
|
||||||
|
MANAGED_DESCENDANT_STARTS+=("$start_time")
|
||||||
|
record_captured_identity "$child_pid" "$start_time"
|
||||||
|
done < <(pgrep -P "$parent_pid" 2>/dev/null || true)
|
||||||
|
}
|
||||||
|
|
||||||
|
record_captured_identity() {
|
||||||
|
local pid="$1" start_time="$2" index
|
||||||
|
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
|
||||||
|
[ "${CAPTURED_DESCENDANT_PIDS[$index]}" = "$pid" ] &&
|
||||||
|
[ "${CAPTURED_DESCENDANT_STARTS[$index]}" = "$start_time" ] && return 0
|
||||||
|
done
|
||||||
|
CAPTURED_DESCENDANT_PIDS+=("$pid")
|
||||||
|
CAPTURED_DESCENDANT_STARTS+=("$start_time")
|
||||||
|
}
|
||||||
|
|
||||||
|
captured_identity_remains() {
|
||||||
|
local index pid start_time
|
||||||
|
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
|
||||||
|
pid="${CAPTURED_DESCENDANT_PIDS[$index]}"
|
||||||
|
start_time="${CAPTURED_DESCENDANT_STARTS[$index]}"
|
||||||
|
is_numeric_pid "$pid" || continue
|
||||||
|
[ "$(process_start_time "$pid" || true)" = "$start_time" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
signal_verified_descendants() {
|
||||||
|
local name="$1" expected_pane_pid="$2" index pid start_time current_pane_pid
|
||||||
|
for index in "${!MANAGED_DESCENDANT_PIDS[@]}"; do
|
||||||
|
pid="${MANAGED_DESCENDANT_PIDS[$index]}"
|
||||||
|
start_time="${MANAGED_DESCENDANT_STARTS[$index]}"
|
||||||
|
is_numeric_pid "$pid" || continue
|
||||||
|
current_pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||||
|
[ "$current_pane_pid" = "$expected_pane_pid" ] || continue
|
||||||
|
[ "$(process_start_time "$pid" || true)" = "$start_time" ] || continue
|
||||||
|
has_pane_ancestry "$pid" "$expected_pane_pid" || continue
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
descendants_remain() {
|
||||||
|
local pane_pid="$1" child_pid
|
||||||
|
is_numeric_pid "$pane_pid" || return 1
|
||||||
|
child_pid="$(pgrep -P "$pane_pid" 2>/dev/null | head -1 || true)"
|
||||||
|
[ -n "$child_pid" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_managed_window() {
|
||||||
|
local name="$1" pane_pid attempt=1
|
||||||
|
case "$name" in api|worker|web|admin|mcp|mobile) ;; *) die "unknown managed window '$name'" ;; esac
|
||||||
|
tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qx "$name" || return 0
|
||||||
|
|
||||||
|
CAPTURED_DESCENDANT_PIDS=("")
|
||||||
|
CAPTURED_DESCENDANT_STARTS=("")
|
||||||
|
while [ "$attempt" -le "$DESCENDANT_SHUTDOWN_ATTEMPTS" ]; do
|
||||||
|
pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||||
|
if ! is_numeric_pid "$pane_pid"; then
|
||||||
|
captured_identity_remains && return 1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! descendants_remain "$pane_pid"; then
|
||||||
|
captured_identity_remains && {
|
||||||
|
warn "window '$name' has a captured descendant outside its pane lineage; leaving it running"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bash 3.2 plus nounset requires non-empty sentinels for these arrays.
|
||||||
|
MANAGED_DESCENDANT_PIDS=("")
|
||||||
|
MANAGED_DESCENDANT_STARTS=("")
|
||||||
|
capture_descendants "$pane_pid"
|
||||||
|
signal_verified_descendants "$name" "$pane_pid"
|
||||||
|
sleep "$DESCENDANT_SHUTDOWN_DELAY_SECONDS"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||||
|
if captured_identity_remains; then
|
||||||
|
warn "window '$name' still has captured descendants; leaving it running"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if is_numeric_pid "$pane_pid" && descendants_remain "$pane_pid"; then
|
||||||
|
warn "window '$name' still has managed descendants; leaving it running"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
window_environment() {
|
||||||
|
local name="$1"
|
||||||
|
# Bash 3.2 with nounset treats an empty array expansion as unbound. Keep a
|
||||||
|
# sentinel and skip it when building tmux's optional -e arguments.
|
||||||
|
WINDOW_ENV=("")
|
||||||
|
WINDOW_FORWARD=''
|
||||||
|
case "$name" in
|
||||||
|
api)
|
||||||
|
WINDOW_ENV=(
|
||||||
|
"SUPABASE_AUTH_URL=$AUTH_URL"
|
||||||
|
"ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID"
|
||||||
|
"ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
|
||||||
|
)
|
||||||
|
WINDOW_FORWARD='SUPABASE_AUTH_URL="$SUPABASE_AUTH_URL" ADMIN_API_SERVICE_USER_ID="$ADMIN_API_SERVICE_USER_ID" ADMIN_API_SERVICE_TOKEN="$ADMIN_API_SERVICE_TOKEN"'
|
||||||
|
;;
|
||||||
|
mcp)
|
||||||
|
WINDOW_ENV=(
|
||||||
|
"SAUCI_ADMIN_API_URL=$LOCAL_API_URL"
|
||||||
|
"SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
|
||||||
|
"SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY"
|
||||||
|
)
|
||||||
|
WINDOW_FORWARD='SAUCI_ADMIN_API_URL="$SAUCI_ADMIN_API_URL" SAUCI_ADMIN_API_TOKEN="$SAUCI_ADMIN_API_TOKEN" SAUCI_MCP_API_KEY="$SAUCI_MCP_API_KEY"'
|
||||||
|
;;
|
||||||
|
mobile)
|
||||||
|
WINDOW_ENV=(
|
||||||
|
"EXPO_PUBLIC_API_URL=$MOBILE_API_URL"
|
||||||
|
"EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL"
|
||||||
|
"EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY"
|
||||||
|
)
|
||||||
|
WINDOW_FORWARD='EXPO_PUBLIC_API_URL="$EXPO_PUBLIC_API_URL" EXPO_PUBLIC_SUPABASE_URL="$EXPO_PUBLIC_SUPABASE_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$EXPO_PUBLIC_SUPABASE_ANON_KEY"'
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +383,8 @@ api_workspace_exists() {
|
|||||||
run_api_script() {
|
run_api_script() {
|
||||||
local script="$1"
|
local script="$1"
|
||||||
api_workspace_exists || die "apps/api is not present yet; cannot run db:$script"
|
api_workspace_exists || die "apps/api is not present yet; cannot run db:$script"
|
||||||
(cd "$ROOT" && env DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
|
(cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \
|
||||||
|
DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
|
||||||
}
|
}
|
||||||
|
|
||||||
start_window() {
|
start_window() {
|
||||||
@@ -98,8 +397,17 @@ start_window() {
|
|||||||
warn "apps/api is not present yet; skipping API window"
|
warn "apps/api is not present yet; skipping API window"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
tmux new-window -t "$SESSION" -n "$name" -c "$ROOT"
|
window_environment "$name"
|
||||||
tmux send-keys -t "$SESSION:$name" "$cmd" C-m
|
local entry escaped_path escaped_home escaped_tmpdir
|
||||||
|
printf -v escaped_path '%q' "$PATH"
|
||||||
|
printf -v escaped_home '%q' "$HOME"
|
||||||
|
printf -v escaped_tmpdir '%q' "${TMPDIR:-/tmp}"
|
||||||
|
set -- tmux new-window -d -t "$SESSION" -n "$name" -c "$ROOT"
|
||||||
|
for entry in "${WINDOW_ENV[@]}"; do
|
||||||
|
[ -n "$entry" ] && set -- "$@" -e "$entry"
|
||||||
|
done
|
||||||
|
set -- "$@" "env -i PATH=$escaped_path HOME=$escaped_home TMPDIR=$escaped_tmpdir $WINDOW_FORWARD $cmd"
|
||||||
|
"$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
port_check() {
|
port_check() {
|
||||||
@@ -115,20 +423,57 @@ port_check() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
report_port_owner() {
|
||||||
|
local port="$1" pid command cwd raw_command
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
[ -n "$pid" ] || continue
|
||||||
|
raw_command="$(ps -p "$pid" -o command= 2>/dev/null)"
|
||||||
|
command="$(printf '%s' "$raw_command" | LC_ALL=C tr -d '[:cntrl:]')"
|
||||||
|
command="${command#"${command%%[![:space:]]*}"}"
|
||||||
|
command="${command%%[[:space:]]*}"
|
||||||
|
[ -n "$command" ] && command="$(basename "$command")"
|
||||||
|
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
|
||||||
|
cwd="$(printf '%s' "$cwd" | LC_ALL=C tr -d '[:cntrl:]')"
|
||||||
|
printf ' PID %s\n command: %s\n cwd: %s\n' "$pid" "${command:-unknown}" "${cwd:-unknown}" >&2
|
||||||
|
done < <(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_app_ports_available() {
|
||||||
|
local entry name port occupied=false
|
||||||
|
local -a app_ports=("api:$API_PORT" "web:$WEB_PORT" "admin:$ADMIN_PORT" "mcp:$MCP_PORT" "metro:$METRO_PORT")
|
||||||
|
for entry in "${app_ports[@]}"; do
|
||||||
|
name="${entry%%:*}"; port="${entry##*:}"
|
||||||
|
if port_up "$port"; then
|
||||||
|
printf '✗ Port %s for %s is already listening. Refusing to replace its owner.\n' "$port" "$name" >&2
|
||||||
|
report_port_owner "$port"
|
||||||
|
occupied=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$occupied" = false ] || return 1
|
||||||
|
}
|
||||||
|
|
||||||
cmd_up() {
|
cmd_up() {
|
||||||
preflight
|
preflight
|
||||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification."
|
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification."
|
||||||
assert_local_auth
|
assert_allowed_auth
|
||||||
|
prepare_session
|
||||||
|
if ! restart_managed_windows; then
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
die "Refusing to restart while a managed window still has descendants."
|
||||||
|
fi
|
||||||
|
if ! assert_app_ports_available; then
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
die "Refusing to start while a configured application port is occupied."
|
||||||
|
fi
|
||||||
ensure_infra
|
ensure_infra
|
||||||
run_api_script migrate
|
run_api_script migrate
|
||||||
run_api_script seed
|
run_api_script seed
|
||||||
tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT"
|
|
||||||
local service
|
local service
|
||||||
for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done
|
for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done
|
||||||
tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
ok "Stack starting in tmux session '$SESSION'"
|
ok "Stack starting in tmux session '$SESSION'"
|
||||||
port_check
|
port_check
|
||||||
printf "\n API: %s/health/live\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n" "$LOCAL_API_URL"
|
printf "\n API: %s/health/live\n Web: http://127.0.0.1:%s\n Admin: http://127.0.0.1:%s\n MCP: http://127.0.0.1:%s/health\n" "$LOCAL_API_URL" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
@@ -152,26 +497,52 @@ cmd_logs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
local name="${1:?usage: restart <service>}" service
|
local name="${1:?usage: restart <service>}" service="" candidate
|
||||||
if [ "$name" = "postgres" ]; then
|
if [ "$name" = "postgres" ]; then
|
||||||
compose restart postgres
|
compose restart postgres
|
||||||
ok "restarted postgres"
|
ok "restarted postgres"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
for candidate in "${SERVERS[@]}"; do
|
||||||
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
if [ "${candidate%%|*}" = "$name" ]; then
|
||||||
for service in "${SERVERS[@]}"; do
|
service="$candidate"
|
||||||
if [ "${service%%|*}" = "$name" ]; then
|
break
|
||||||
start_window "$name" "${service#*|}"
|
|
||||||
ok "restarted $name"
|
|
||||||
return
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
die "unknown service '$name'"
|
[ -n "$service" ] || die "unknown service '$name'"
|
||||||
|
|
||||||
|
preflight
|
||||||
|
case "$name" in
|
||||||
|
api|mobile)
|
||||||
|
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
||||||
|
assert_allowed_auth
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[ "$name" != "mobile" ] || [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
||||||
|
|
||||||
|
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||||
|
prepare_session
|
||||||
|
if ! stop_managed_window "$name"; then
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
die "Refusing to restart '$name' while its managed descendants remain."
|
||||||
|
fi
|
||||||
|
start_window "$name" "${service#*|}"
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
ok "restarted $name"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_down() {
|
cmd_down() {
|
||||||
tmux kill-session -t "$SESSION" 2>/dev/null && ok "application services stopped" || warn "no session '$SESSION'"
|
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||||
|
prepare_session
|
||||||
|
if ! restart_managed_windows; then
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
die "Refusing to stop infrastructure while a managed window still has descendants."
|
||||||
|
fi
|
||||||
|
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||||
|
ok "managed application services stopped"
|
||||||
|
else
|
||||||
|
warn "no session '$SESSION'"
|
||||||
|
fi
|
||||||
if [ "${1:-}" = "--all" ]; then
|
if [ "${1:-}" = "--all" ]; then
|
||||||
compose down
|
compose down
|
||||||
ok "standalone backend infrastructure stopped (data retained)"
|
ok "standalone backend infrastructure stopped (data retained)"
|
||||||
@@ -203,9 +574,10 @@ cmd_native() {
|
|||||||
preflight
|
preflight
|
||||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
||||||
[ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
[ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
||||||
assert_local_auth
|
assert_allowed_auth
|
||||||
ensure_infra
|
ensure_infra
|
||||||
(cd "$ROOT" && env EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
|
(cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \
|
||||||
|
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
|
||||||
EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \
|
EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \
|
||||||
npm run "$platform" -w @sauci/mobile)
|
npm run "$platform" -w @sauci/mobile)
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user