mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504eddaf68 | ||
|
|
f22d4a3649 | ||
|
|
45d24911fb | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
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"
|
||||
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"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||
skill. Implement only the delegated objective and preserve unrelated work. Update
|
||||
maintained contract documentation and add focused tests when the contract changes.
|
||||
Run the required focused checks and return changed files, exact results,
|
||||
assumptions, and blockers. 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.
|
||||
skill. Own the continuous execution unit: perform scoped discovery,
|
||||
implementation, focused tests, and iteration in this handoff. Implement only the
|
||||
delegated objective and preserve unrelated work. Update maintained contract
|
||||
documentation and add focused tests when the contract changes. Run the required
|
||||
focused checks and return changed files, exact results, assumptions, and blockers.
|
||||
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.
|
||||
"""
|
||||
@@ -54,5 +54,9 @@ 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. Report the artifact, version/build
|
||||
number, upload result when authorized, and store read-back state.
|
||||
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
|
||||
|
||||
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
|
||||
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. Give a fresh read-only verifier the acceptance criteria. It drives browser
|
||||
1. Package-local deterministic checks do not need a running stack. For runtime
|
||||
verification, reuse a compatible running local stack when available; otherwise
|
||||
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
|
||||
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
|
||||
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
||||
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
||||
3. The verifier writes screenshots, traces, video, or response evidence under
|
||||
`evidence/` and returns only expected, observed, evidence paths, and
|
||||
`works|broken`. It never edits code.
|
||||
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and
|
||||
report the unresolved evidence.
|
||||
5. After the task works, run `npm run verify:fast`, affected E2E, and
|
||||
`npm run verify:full` when local Supabase/Deno are available. Never weaken an
|
||||
assertion just to pass.
|
||||
4. If independent verification is broken, fix and use a fresh verifier. Stop
|
||||
after three failed rounds and report the unresolved evidence.
|
||||
5. Run focused checks for the affected change. Run affected E2E when the runtime
|
||||
path requires it. Run `npm run verify:full` when its prerequisites are
|
||||
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
|
||||
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
||||
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
|
||||
.next
|
||||
**/.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
@@ -23,11 +25,15 @@ apps/mcp
|
||||
apps/mobile
|
||||
apps/supabase
|
||||
apps/web
|
||||
!apps/web
|
||||
!apps/web/**
|
||||
e2e
|
||||
evidence
|
||||
etc
|
||||
infra
|
||||
scripts
|
||||
!scripts
|
||||
!scripts/fix-hoisting.js
|
||||
terraform
|
||||
.agents
|
||||
.claude
|
||||
|
||||
@@ -25,11 +25,21 @@ MCP service in one npm/Turborepo workspace.
|
||||
and prove the relevant compatibility contracts still hold. Preserve deployed
|
||||
interfaces and supported client behavior; repository checks alone do not
|
||||
prove a live change is safe.
|
||||
10. Scope work to the task. Route bounded implementation through the project
|
||||
`implementation` agent when delegation is useful; the primary agent retains
|
||||
acceptance, review, and final verification. Do not delegate trivial edits.
|
||||
10. The Sol primary agent owns scope, risk classification, acceptance criteria,
|
||||
progress steering, final diff and evidence review, and the final answer.
|
||||
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
|
||||
`docs/agents/routing.md`.
|
||||
12. Agents must not use em dash characters in text they add to this repository.
|
||||
|
||||
## 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;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- The seeded quiz pool of 12 questions was small enough that repeat sessions for
|
||||
-- the same couple felt almost identical. Widen the pool so the never-used-first
|
||||
-- selection strategy in the repository has enough fresh material to draw from.
|
||||
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||
('What is your ideal pet?', 'What is your partner''s ideal pet?',
|
||||
ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130),
|
||||
('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?',
|
||||
ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140),
|
||||
('What feature would your dream house have?', 'What feature would your partner''s dream house have?',
|
||||
ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150),
|
||||
('What is your favourite season?', 'What is your partner''s favourite season?',
|
||||
ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160),
|
||||
('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?',
|
||||
ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170),
|
||||
('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?',
|
||||
ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180),
|
||||
('What is your worst phone habit?', 'What is your partner''s worst phone habit?',
|
||||
ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190),
|
||||
('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?',
|
||||
ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200),
|
||||
('How do you behave during a board game?', 'How does your partner behave during a board game?',
|
||||
ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210),
|
||||
('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?',
|
||||
ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220),
|
||||
('How well do you handle scary movies?', 'How well does your partner handle scary movies?',
|
||||
ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230),
|
||||
('What is your dance floor style?', 'What is your partner''s dance floor style?',
|
||||
ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240),
|
||||
('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?',
|
||||
ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250),
|
||||
('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?',
|
||||
ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260),
|
||||
('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?',
|
||||
ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270),
|
||||
('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?',
|
||||
ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280),
|
||||
('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?',
|
||||
ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290),
|
||||
('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?',
|
||||
ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300),
|
||||
('How would you describe your social battery?', 'How would you describe your partner''s social battery?',
|
||||
ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310),
|
||||
('What is your gift-giving style?', 'What is your partner''s gift-giving style?',
|
||||
ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320),
|
||||
('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?',
|
||||
ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330),
|
||||
('What weather do you prefer?', 'What weather does your partner prefer?',
|
||||
ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340),
|
||||
('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?',
|
||||
ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350),
|
||||
('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?',
|
||||
ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360)
|
||||
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Optional proof-of-completion for dares. The sender picks a requirement
|
||||
-- ('none', 'photo', 'audio') at send time for both catalogue and custom dares;
|
||||
-- the recipient attaches a media object when submitting.
|
||||
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_type text NOT NULL DEFAULT 'none';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_proof_type_check;
|
||||
ALTER TABLE sent_dares ADD CONSTRAINT sent_dares_proof_type_check
|
||||
CHECK (proof_type IN ('none', 'photo', 'audio'));
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_proof_media_idx ON sent_dares(proof_media_id) WHERE proof_media_id IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE media_objects DROP CONSTRAINT IF EXISTS media_objects_kind_check;
|
||||
ALTER TABLE media_objects ADD CONSTRAINT media_objects_kind_check
|
||||
CHECK (kind IN ('avatar', 'response', 'chat', 'feedback', 'dare_proof'));
|
||||
@@ -134,6 +134,20 @@
|
||||
"when": 1788081480000,
|
||||
"tag": "0018_cutover_source_parity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1788168000000,
|
||||
"tag": "0019_quiz",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1788168060000,
|
||||
"tag": "0020_quiz_question_pool",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
|
||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||
import type { DaresRepository } from './domains/dares/repository.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 type { CoupleService } from './domains/couples/service.js';
|
||||
import type { PacksRepository } from './domains/packs/repository.js';
|
||||
@@ -39,6 +41,7 @@ export interface AppDependencies {
|
||||
answersRepository?: AnswersRepository;
|
||||
chatRepository?: ChatRepository;
|
||||
daresRepository?: DaresRepository;
|
||||
quizRepository?: QuizRepository;
|
||||
profileSettingsRepository?: ProfileSettingsRepository;
|
||||
accountOperationsService?: AccountOperationsService;
|
||||
mediaRepository?: MediaRepository;
|
||||
@@ -126,6 +129,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||
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/chat/schema.js';
|
||||
export * from '../domains/app-data/schema.js';
|
||||
export * from '../domains/quiz/schema.js';
|
||||
@@ -4,6 +4,11 @@ import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../d
|
||||
export type DareStatus =
|
||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||
|
||||
/** Sender-chosen proof of completion; 'none' means a plain "I did it" suffices. */
|
||||
export type DareProofType = 'none' | 'photo' | 'audio';
|
||||
|
||||
export const DARE_PROOF_TYPES: readonly DareProofType[] = ['none', 'photo', 'audio'];
|
||||
|
||||
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
||||
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
||||
|
||||
@@ -45,6 +50,8 @@ export interface SentDare {
|
||||
direction: 'incoming' | 'outgoing';
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
proof_type: DareProofType;
|
||||
proof_media_id: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
@@ -79,6 +86,7 @@ export interface SendDareInput {
|
||||
custom_dare_intensity?: number | null;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
proof_type?: DareProofType | null;
|
||||
}
|
||||
|
||||
export class DaresError extends Error {
|
||||
@@ -111,6 +119,8 @@ interface SentDareRecord extends QueryResultRow {
|
||||
recipient_id: string;
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
proof_type: DareProofType;
|
||||
proof_media_id: string | null;
|
||||
sent_at: Date;
|
||||
accepted_at: Date | null;
|
||||
submitted_at: Date | null;
|
||||
@@ -124,7 +134,7 @@ export interface DaresRepository {
|
||||
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
||||
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
||||
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
||||
submit(userId: string, dareId: string): Promise<SentDare>;
|
||||
submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare>;
|
||||
complete(userId: string, dareId: string): Promise<SentDare>;
|
||||
cancel(userId: string, dareId: string): Promise<SentDare>;
|
||||
stats(userId: string): Promise<DareStats>;
|
||||
@@ -132,7 +142,7 @@ export interface DaresRepository {
|
||||
}
|
||||
|
||||
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
||||
custom_dare_text, sender_id, recipient_id, status, sender_notes,
|
||||
custom_dare_text, sender_id, recipient_id, status, sender_notes, proof_type, proof_media_id,
|
||||
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
||||
|
||||
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
||||
@@ -172,6 +182,8 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
||||
status: row.status,
|
||||
sender_notes: row.sender_notes,
|
||||
proof_type: row.proof_type,
|
||||
proof_media_id: row.proof_media_id,
|
||||
sent_at: row.sent_at.toISOString(),
|
||||
accepted_at: iso(row.accepted_at),
|
||||
submitted_at: iso(row.submitted_at),
|
||||
@@ -323,6 +335,11 @@ export class PostgresDaresRepository implements DaresRepository {
|
||||
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
||||
}
|
||||
|
||||
const proofType = input.proof_type ?? 'none';
|
||||
if (!DARE_PROOF_TYPES.includes(proofType)) {
|
||||
throw new DaresError('invalid_proof_type', 'Proof must be none, photo, or audio', 400);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
let intensity: number;
|
||||
let dareId: string | null = null;
|
||||
@@ -376,15 +393,15 @@ export class PostgresDaresRepository implements DaresRepository {
|
||||
`insert into sent_dares (
|
||||
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
||||
dare_text_snapshot, dare_intensity_snapshot,
|
||||
sender_id, recipient_id, sender_notes, expires_at
|
||||
sender_id, recipient_id, sender_notes, proof_type, expires_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
case when $11::integer is null then null else now() + ($11::integer * interval '1 hour') end
|
||||
)
|
||||
returning ${BARE_COLUMNS}`,
|
||||
[
|
||||
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
||||
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
|
||||
userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration,
|
||||
],
|
||||
);
|
||||
return toSentDare(result.rows[0]!, userId);
|
||||
@@ -438,8 +455,59 @@ export class PostgresDaresRepository implements DaresRepository {
|
||||
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
||||
}
|
||||
|
||||
async submit(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
|
||||
/**
|
||||
* Submission enforces the sender's proof requirement: a proof-required dare
|
||||
* cannot be marked done without media of the matching type. Proof is also
|
||||
* accepted voluntarily on 'none' dares. Media ownership, kind, and couple are
|
||||
* re-checked here so a submit cannot reference someone else's upload.
|
||||
*/
|
||||
async submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const existing = await client.query<{
|
||||
sender_id: string; recipient_id: string; status: DareStatus;
|
||||
proof_type: DareProofType; couple_id: string;
|
||||
}>(
|
||||
'select sender_id, recipient_id, status, proof_type, couple_id from sent_dares where id = $1 for update',
|
||||
[dareId],
|
||||
);
|
||||
const dare = existing.rows[0];
|
||||
if (!dare || (dare.sender_id !== userId && dare.recipient_id !== userId)) {
|
||||
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
}
|
||||
if (dare.recipient_id !== userId) {
|
||||
throw new DaresError('not_permitted', 'Only the recipient can do that', 403);
|
||||
}
|
||||
if (dare.status !== 'active') {
|
||||
throw new DaresError('invalid_transition', `A ${dare.status} dare cannot change to submitted`, 409);
|
||||
}
|
||||
if (dare.proof_type !== 'none' && !proofMediaId) {
|
||||
throw new DaresError('proof_required', `This dare requires ${dare.proof_type} proof`, 400);
|
||||
}
|
||||
if (proofMediaId) {
|
||||
const media = await client.query<{ kind: string; mime_type: string; owner_id: string; couple_id: string | null }>(
|
||||
'select kind, mime_type, owner_id, couple_id from media_objects where id = $1 and deleted_at is null',
|
||||
[proofMediaId],
|
||||
);
|
||||
const proof = media.rows[0];
|
||||
if (!proof || proof.owner_id !== userId || proof.kind !== 'dare_proof' || proof.couple_id !== dare.couple_id) {
|
||||
throw new DaresError('proof_not_found', 'Proof media not found', 404);
|
||||
}
|
||||
const category = dare.proof_type === 'none'
|
||||
? (proof.mime_type.startsWith('image/') || proof.mime_type.startsWith('audio/'))
|
||||
: proof.mime_type.startsWith(dare.proof_type === 'photo' ? 'image/' : 'audio/');
|
||||
if (!category) {
|
||||
throw new DaresError('proof_type_mismatch', `This dare requires ${dare.proof_type} proof`, 400);
|
||||
}
|
||||
}
|
||||
const result = await client.query<SentDareRecord>(
|
||||
`update sent_dares
|
||||
set status = 'submitted', submitted_at = now(), proof_media_id = $2::uuid
|
||||
where id = $1
|
||||
returning ${BARE_COLUMNS}`,
|
||||
[dareId, proofMediaId ?? null],
|
||||
);
|
||||
return toSentDare(result.rows[0]!, userId);
|
||||
});
|
||||
}
|
||||
|
||||
async complete(userId: string, dareId: string): Promise<SentDare> {
|
||||
|
||||
@@ -15,6 +15,7 @@ const sendBody = z
|
||||
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
||||
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
||||
sender_notes: z.string().trim().max(500).nullish(),
|
||||
proof_type: z.enum(['none', 'photo', 'audio']).nullish(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
||||
@@ -23,6 +24,8 @@ const sendBody = z
|
||||
|
||||
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
||||
|
||||
const submitBody = z.object({ proof_media_id: id.nullish() }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
@@ -93,8 +96,20 @@ export function registerDareRoutes(app: DaresApp, repository: DaresRepository):
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/dares/:dareId/submit', async (c) => {
|
||||
const dareId = id.safeParse(c.req.param('dareId'));
|
||||
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||
// Body is optional: dares without a proof requirement submit with no payload.
|
||||
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!body.success) return c.json(error('invalid_proof', 'proof_media_id must be a valid media ID'), 400);
|
||||
const response = await result(() =>
|
||||
repository.submit(c.get('identity').id, dareId.data, body.data.proof_media_id ?? null));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
for (const [path, method] of [
|
||||
['submit', 'submit'],
|
||||
['complete', 'complete'],
|
||||
['cancel', 'cancel'],
|
||||
] as const) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
||||
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
||||
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
||||
const coupleId = profile.rows[0].couple_id;
|
||||
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
||||
if ((kind === 'response' || kind === 'chat' || kind === 'dare_proof') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
||||
if (kind === 'response') {
|
||||
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
||||
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
||||
@@ -84,7 +84,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
||||
from media_objects mo join profiles viewer on viewer.id=$1
|
||||
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
||||
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
||||
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
||||
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat','dare_proof') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
||||
[userId, mediaId],
|
||||
);
|
||||
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
||||
|
||||
@@ -8,8 +8,9 @@ const allowed: Record<MediaKind, ReadonlySet<string>> = {
|
||||
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
||||
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
dare_proof: new Set(['image/jpeg', 'image/png', 'image/webp', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||
};
|
||||
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 };
|
||||
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 };
|
||||
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
||||
|
||||
export class FilesystemMediaStorage {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
|
||||
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback', 'dare_proof'] as const;
|
||||
export type MediaKind = typeof mediaKinds[number];
|
||||
|
||||
export interface MediaObject {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
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 };
|
||||
}
|
||||
|
||||
// Prefer questions this couple has never had in any prior session (completed or
|
||||
// active), then the least-recently-used ones, with a random tiebreak within each
|
||||
// group so repeat quizzes stay varied instead of always reusing the same pool.
|
||||
const questions = await client.query<{ id: string }>(
|
||||
`select q.id
|
||||
from quiz_questions q
|
||||
left join (
|
||||
select question_id, max(created_at) as last_used
|
||||
from quiz_sessions, unnest(question_ids) as question_id
|
||||
where couple_id = $1
|
||||
group by question_id
|
||||
) usage on usage.question_id = q.id
|
||||
where q.is_active = true
|
||||
order by (usage.last_used is not null), usage.last_used asc, random()
|
||||
limit $2`,
|
||||
[coupleId, 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 { PostgresChatRepository } from './domains/chat/repository.js';
|
||||
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||
import { PostgresQuizRepository } from './domains/quiz/repository.js';
|
||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||
import { CoupleService } from './domains/couples/service.js';
|
||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
|
||||
const answersRepository = new PostgresAnswersRepository(pool);
|
||||
const chatRepository = new PostgresChatRepository(pool);
|
||||
const daresRepository = new PostgresDaresRepository(pool);
|
||||
const quizRepository = new PostgresQuizRepository(pool);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
@@ -68,6 +70,7 @@ const app = createApp({
|
||||
answersRepository,
|
||||
chatRepository,
|
||||
daresRepository,
|
||||
quizRepository,
|
||||
profileSettingsRepository,
|
||||
accountOperationsService,
|
||||
mediaRepository,
|
||||
|
||||
@@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||
});
|
||||
|
||||
async function insertProof(owner: string, coupleId: string | null, mime: string, kind = 'dare_proof') {
|
||||
const id = randomUUID();
|
||||
await pool.query(
|
||||
'insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,$4,$5,$6,100)',
|
||||
[id, owner, coupleId, kind, `${kind}/test/${id}.bin`, mime],
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('stores the proof requirement and defaults to none', async () => {
|
||||
const plain = await repo.send(sender, { dare_id: freeDare });
|
||||
expect(plain).toMatchObject({ proof_type: 'none', proof_media_id: null });
|
||||
const proofed = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
expect(proofed.proof_type).toBe('photo');
|
||||
await expect(repo.send(sender, { dare_id: freeDare, proof_type: 'video' as never }))
|
||||
.rejects.toMatchObject({ code: 'invalid_proof_type' });
|
||||
});
|
||||
|
||||
it('requires matching proof media before a proofed dare can be submitted', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
|
||||
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'proof_required' });
|
||||
const audio = await insertProof(recipient, couple, 'audio/mp4');
|
||||
await expect(repo.submit(recipient, dare.id, audio)).rejects.toMatchObject({ code: 'proof_type_mismatch' });
|
||||
|
||||
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||
expect(submitted).toMatchObject({ status: 'submitted', proof_media_id: photo });
|
||||
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||
});
|
||||
|
||||
it('refuses proof media the recipient does not own or that is not dare proof', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
|
||||
const foreign = await insertProof(outsider, otherCouple, 'image/jpeg');
|
||||
await expect(repo.submit(recipient, dare.id, foreign)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
const wrongKind = await insertProof(recipient, couple, 'image/jpeg', 'chat');
|
||||
await expect(repo.submit(recipient, dare.id, wrongKind)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
const senderOwned = await insertProof(sender, couple, 'image/jpeg');
|
||||
await expect(repo.submit(recipient, dare.id, senderOwned)).rejects.toMatchObject({ code: 'proof_not_found' });
|
||||
});
|
||||
|
||||
it('accepts voluntary proof on dares that did not require any', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
const photo = await insertProof(recipient, couple, 'image/jpeg');
|
||||
const submitted = await repo.submit(recipient, dare.id, photo);
|
||||
expect(submitted).toMatchObject({ status: 'submitted', proof_type: 'none', proof_media_id: photo });
|
||||
});
|
||||
|
||||
it('rejects durations outside the offered presets', async () => {
|
||||
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||
});
|
||||
|
||||
@@ -13,10 +13,12 @@ const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||
const dare: SentDare = {
|
||||
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null, proof_type: 'none', proof_media_id: null,
|
||||
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
const proofMediaId = '77777777-7777-4777-8777-777777777777';
|
||||
|
||||
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||
const repository: DaresRepository = {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
@@ -91,7 +93,7 @@ describe('dare routes', () => {
|
||||
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||
await post(app, `/v1/dares/${dareId}/submit`);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null);
|
||||
await post(app, `/v1/dares/${dareId}/complete`);
|
||||
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||
@@ -109,6 +111,22 @@ describe('dare routes', () => {
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it('accepts a proof requirement on send and rejects unknown ones', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'photo' });
|
||||
expect(response.status).toBe(201);
|
||||
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ proof_type: 'photo' }));
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'video' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('passes proof media through on submit and validates its shape', async () => {
|
||||
const { app, repository } = setup();
|
||||
await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: proofMediaId });
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, proofMediaId);
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: 'not-a-uuid' })).status).toBe(400);
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`, { extra_field: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('exposes the duration presets the send sheet offers', async () => {
|
||||
const { app } = setup();
|
||||
const response = await app.request('/v1/dares/durations');
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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';
|
||||
const userC = '88888888-8888-4888-8888-888888888888';
|
||||
|
||||
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),($8,$7)`,
|
||||
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('draws the next session from never-used questions once a session completes', async () => {
|
||||
const { session: first } = await repo.startSession(userA);
|
||||
const firstQuestionIds = first.questions.map((question) => question.id);
|
||||
expect(firstQuestionIds).toHaveLength(10);
|
||||
|
||||
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||
await repo.submitAnswers(userA, first.id, answers);
|
||||
const completed = await repo.submitAnswers(userB, first.id, answers);
|
||||
expect(completed.status).toBe('completed');
|
||||
|
||||
const { session: second, created } = await repo.startSession(userA);
|
||||
expect(created).toBe(true);
|
||||
const secondQuestionIds = second.questions.map((question) => question.id);
|
||||
expect(secondQuestionIds).toHaveLength(10);
|
||||
|
||||
// The seeded pool has 36 active questions, so the never-used-first preference
|
||||
// guarantees the second session's questions are wholly disjoint from the first.
|
||||
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
|
||||
expect(overlap).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes selection to each couple independently', async () => {
|
||||
const { session: coupleOneFirst } = await repo.startSession(userA);
|
||||
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
|
||||
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
|
||||
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
|
||||
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
|
||||
|
||||
// A different couple with no prior sessions should still be able to draw a
|
||||
// fresh set of 10 questions regardless of what the first couple has used,
|
||||
// since the never-used-for-them preference is scoped per couple.
|
||||
const { session: otherSession, created } = await repo.startSession(outsider);
|
||||
expect(created).toBe(true);
|
||||
expect(otherSession.questions).toHaveLength(10);
|
||||
|
||||
const partnerView = await repo.startSession(userC);
|
||||
expect(partnerView.created).toBe(false);
|
||||
expect(partnerView.session.id).toBe(otherSession.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'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 47
|
||||
versionName "1.0.5"
|
||||
versionCode 48
|
||||
versionName "1.0.6"
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<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_MEDIA_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||
<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.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
@@ -52,4 +52,4 @@
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
+30
-3
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Sauci",
|
||||
"slug": "sauci",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "app.sauci",
|
||||
@@ -16,12 +16,18 @@
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.sauci.app",
|
||||
"associatedDomains": [
|
||||
"applinks:sauci.app"
|
||||
],
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"entitlements": {
|
||||
"com.apple.security.application-groups": [
|
||||
"group.com.sauci.app"
|
||||
]
|
||||
},
|
||||
"buildNumber": "43",
|
||||
"buildNumber": "45",
|
||||
"usesAppleSignIn": true,
|
||||
"googleServicesFile": "./GoogleService-Info.plist",
|
||||
"infoPlist": {
|
||||
@@ -183,7 +189,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 47,
|
||||
"versionCode": 48,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
@@ -191,6 +197,23 @@
|
||||
"android.permission.USE_BIOMETRIC",
|
||||
"android.permission.USE_FINGERPRINT",
|
||||
"com.android.vending.BILLING"
|
||||
],
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
"autoVerify": true,
|
||||
"data": [
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "sauci.app",
|
||||
"pathPrefix": "/join"
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
"BROWSABLE",
|
||||
"DEFAULT"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
@@ -208,6 +231,10 @@
|
||||
"buildToolsVersion": "36.0.0",
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 36,
|
||||
"blockedPermissions": [
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO"
|
||||
],
|
||||
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||
"useLegacyPackaging": false,
|
||||
"manifestPlaceholders": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as Haptics from 'expo-haptics';
|
||||
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
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 { Events } from '../../src/lib/analytics';
|
||||
import { colors, spacing, typography } from '../../src/theme';
|
||||
@@ -166,6 +166,9 @@ export default function DiscoveryScreen() {
|
||||
{/* Dares */}
|
||||
<DaresTile delay={200} />
|
||||
|
||||
{/* Quiz */}
|
||||
<QuizTile delay={225} />
|
||||
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
|
||||
@@ -17,17 +17,24 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { coupleApi } from "../../src/lib/coupleApi";
|
||||
import { coupleApi } from "../../src/lib/coupleApi";
|
||||
import { getPairingError } from "../../src/lib/errors";
|
||||
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 { 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() {
|
||||
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 [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
|
||||
useEffect(() => {
|
||||
@@ -36,25 +43,83 @@ export default function PairingScreen() {
|
||||
}
|
||||
}, [couple, partner]);
|
||||
|
||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
||||
// be added later without coupling product data back to Supabase.
|
||||
useEffect(() => {
|
||||
if (!couple || partner) return;
|
||||
// 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
|
||||
// be added later without coupling product data back to Supabase.
|
||||
useEffect(() => {
|
||||
if (!couple || partner) return;
|
||||
|
||||
// Poll every 5 seconds as fallback
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchCouple();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [couple, partner, fetchCouple]);
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [couple, partner, fetchCouple]);
|
||||
|
||||
const handleCreateCouple = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.create();
|
||||
await coupleApi.create();
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
@@ -76,7 +141,7 @@ export default function PairingScreen() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.join(sanitizedCode);
|
||||
await coupleApi.join(sanitizedCode);
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
@@ -100,8 +165,13 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteCodeChange = (value: string) => {
|
||||
setWasPrefilled(false);
|
||||
setInviteCode(value);
|
||||
};
|
||||
|
||||
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 () => {
|
||||
@@ -144,7 +214,7 @@ export default function PairingScreen() {
|
||||
onPress: async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.cancel();
|
||||
await coupleApi.cancel();
|
||||
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
@@ -338,6 +408,37 @@ export default function PairingScreen() {
|
||||
</Text>
|
||||
</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 */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
@@ -346,14 +447,20 @@ export default function PairingScreen() {
|
||||
<Text style={styles.sectionTitle}>Have a code?</Text>
|
||||
<GlassCard>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, wasPrefilled && styles.inputPrefilled]}
|
||||
placeholder="Enter invite code"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
value={inviteCode}
|
||||
onChangeText={setInviteCode}
|
||||
onChangeText={handleInviteCodeChange}
|
||||
autoCapitalize="characters"
|
||||
maxLength={8}
|
||||
testID="invite-code-input"
|
||||
/>
|
||||
{wasPrefilled && (
|
||||
<Text style={styles.prefilledCaption}>
|
||||
Code applied from your invite link
|
||||
</Text>
|
||||
)}
|
||||
<GlassButton
|
||||
onPress={handleJoinCouple}
|
||||
disabled={isSubmitting}
|
||||
@@ -436,6 +543,50 @@ const styles = StyleSheet.create({
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
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: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,232 +1,5 @@
|
||||
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
|
||||
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;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
import { QuizScreen } from "../../src/features/quiz";
|
||||
|
||||
export default function QuizRoute() {
|
||||
return <QuizScreen />;
|
||||
}
|
||||
@@ -43,9 +43,12 @@ const GestureHandlerRootView = Platform.OS === "web"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
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 { 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();
|
||||
|
||||
@@ -117,6 +120,22 @@ export default function RootLayout() {
|
||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
||||
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 {
|
||||
// Parse the URL to extract auth parameters
|
||||
const parsedUrl = new URL(url);
|
||||
@@ -148,7 +167,7 @@ export default function RootLayout() {
|
||||
if (accessToken && refreshToken) {
|
||||
// OAuth flow - set session directly
|
||||
console.log('[DeepLink] Setting session with tokens');
|
||||
const { data, error } = await authClient.auth.setSession({
|
||||
const { data, error } = await authClient.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
@@ -160,7 +179,7 @@ export default function RootLayout() {
|
||||
} else if (tokenHash && type) {
|
||||
// Magic link / email verification flow - verify the OTP
|
||||
console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
|
||||
const { data, error } = await authClient.auth.verifyOtp({
|
||||
const { data, error } = await authClient.auth.verifyOtp({
|
||||
token_hash: tokenHash,
|
||||
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
|
||||
});
|
||||
@@ -174,12 +193,12 @@ export default function RootLayout() {
|
||||
} else {
|
||||
// Fallback for web or other scenarios
|
||||
console.log('[DeepLink] No auth params found, falling back to getSession');
|
||||
await authClient.auth.getSession();
|
||||
await authClient.auth.getSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeepLink] Error handling deep link:', error);
|
||||
// Still try to get session as fallback
|
||||
await authClient.auth.getSession();
|
||||
await authClient.auth.getSession();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,7 +225,7 @@ export default function RootLayout() {
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
||||
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { needsOnboarding } from "../src/constants/onboarding";
|
||||
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
|
||||
export default function Index() {
|
||||
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
|
||||
if (isLoading) {
|
||||
@@ -25,6 +48,26 @@ export default function Index() {
|
||||
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
|
||||
// No forced redirect to pairing for users without a couple
|
||||
return <Redirect href="/(app)" />;
|
||||
|
||||
@@ -3804,8 +3804,8 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
|
||||
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
|
||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
||||
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
|
||||
EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d
|
||||
@@ -3851,8 +3851,8 @@ SPEC CHECKSUMS:
|
||||
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
|
||||
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
|
||||
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
||||
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
||||
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||
|
||||
@@ -570,7 +570,7 @@
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.5;
|
||||
MARKETING_VERSION = 1.0.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -599,7 +599,7 @@
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.5;
|
||||
MARKETING_VERSION = 1.0.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -772,7 +772,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -782,7 +782,7 @@
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
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;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
||||
@@ -808,7 +808,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -818,7 +818,7 @@
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
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_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.5</string>
|
||||
<string>1.0.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -47,9 +47,11 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>43</string>
|
||||
<string>45</string>
|
||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||
<false/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type { SentDare } from "@/features/dares/types";
|
||||
const incoming: SentDare = {
|
||||
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
|
||||
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
|
||||
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z",
|
||||
status: "pending", sender_notes: null, proof_type: "none", proof_media_id: null, sent_at: "2026-08-28T10:00:00.000Z",
|
||||
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
@@ -55,6 +55,23 @@ describe("useDares", () => {
|
||||
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" });
|
||||
});
|
||||
|
||||
it("attaches proof media to a submit when one is supplied", async () => {
|
||||
mockLoad();
|
||||
const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "submitted" } } as never);
|
||||
const { result } = renderHook(() => useDares());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submit("dare-1", "media-1");
|
||||
});
|
||||
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/submit", { proof_media_id: "media-1" });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submit("dare-1");
|
||||
});
|
||||
expect(postSpy).toHaveBeenLastCalledWith("/v1/dares/dare-1/submit", undefined);
|
||||
});
|
||||
|
||||
it("routes a premium refusal to the paywall rather than the error banner", async () => {
|
||||
mockLoad();
|
||||
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402));
|
||||
|
||||
@@ -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 * as MediaLibrary from 'expo-media-library';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
@@ -41,12 +41,13 @@ describe('useMediaSaver', () => {
|
||||
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('initializes with saving as false', () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(typeof result.current.saveMedia).toBe('function');
|
||||
});
|
||||
it('initializes with saving as false', () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
|
||||
expect(result.current.saving).toBe(false);
|
||||
expect(typeof result.current.saveMedia).toBe('function');
|
||||
expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
|
||||
});
|
||||
|
||||
it('saves local image successfully', async () => {
|
||||
const { result } = renderHook(() => useMediaSaver());
|
||||
@@ -179,10 +180,8 @@ describe('useMediaSaver', () => {
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('sets saving to true during operation', async () => {
|
||||
let savingDuringOperation = false;
|
||||
|
||||
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
||||
it('sets saving to true during operation', async () => {
|
||||
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
|
||||
// This would capture the saving state during the async operation
|
||||
// but since we can't access result.current here synchronously,
|
||||
// we'll test this differently
|
||||
|
||||
@@ -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 { LiveDrawTile } from './LiveDrawTile';
|
||||
export { DaresTile } from './DaresTile';
|
||||
export { QuizTile } from './QuizTile';
|
||||
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
||||
@@ -30,6 +30,7 @@ interface GlassButtonProps {
|
||||
textStyle?: TextStyle;
|
||||
haptic?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
@@ -46,6 +47,7 @@ export function GlassButton({
|
||||
textStyle,
|
||||
haptic = true,
|
||||
icon,
|
||||
testID,
|
||||
}: GlassButtonProps) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
@@ -104,6 +106,7 @@ export function GlassButton({
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || loading}
|
||||
testID={testID}
|
||||
style={[
|
||||
animatedStyle,
|
||||
fullWidth && styles.fullWidth,
|
||||
|
||||
@@ -6,9 +6,9 @@ import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { Paywall } from "../../components/paywall";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../theme";
|
||||
import { DareCard, DarePackGrid, SendDareSheet } from "./components";
|
||||
import { DareCard, DarePackGrid, DareProofSheet, SendDareSheet } from "./components";
|
||||
import { useDares } from "./hooks/useDares";
|
||||
import type { DarePack } from "./types";
|
||||
import type { DarePack, SentDare } from "./types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
@@ -27,6 +27,7 @@ export function DaresScreen() {
|
||||
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [paywallOpen, setPaywallOpen] = useState(false);
|
||||
const [proofDare, setProofDare] = useState<SentDare | null>(null);
|
||||
|
||||
const openPaywall = useCallback(() => {
|
||||
setSheetOpen(false);
|
||||
@@ -68,6 +69,14 @@ export function DaresScreen() {
|
||||
void dares.respond(dareId, action);
|
||||
return;
|
||||
}
|
||||
if (action === "submit") {
|
||||
// Proof-required dares detour through the capture sheet before submitting.
|
||||
const dare = dares.active.find((candidate) => candidate.id === dareId);
|
||||
if (dare && dare.proof_type !== "none") {
|
||||
setProofDare(dare);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void dares[action](dareId);
|
||||
},
|
||||
[dares],
|
||||
@@ -173,6 +182,12 @@ export function DaresScreen() {
|
||||
onRequestPremium={openPaywall}
|
||||
/>
|
||||
|
||||
<DareProofSheet
|
||||
dare={proofDare}
|
||||
onClose={() => setProofDare(null)}
|
||||
onSubmit={(dareId, proofMediaId) => dares.submit(dareId, proofMediaId)}
|
||||
/>
|
||||
|
||||
<Paywall
|
||||
visible={paywallOpen}
|
||||
onClose={closePaywall}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from "react";
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import { DareProofView } from "./DareProofView";
|
||||
import type { SentDare } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
@@ -92,6 +93,23 @@ export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCar
|
||||
|
||||
{dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null}
|
||||
|
||||
{dare.proof_type !== "none" ? (
|
||||
<View style={styles.proofRow}>
|
||||
<Ionicons
|
||||
name={dare.proof_type === "photo" ? "camera-outline" : "mic-outline"}
|
||||
size={13}
|
||||
color={ACCENT}
|
||||
/>
|
||||
<Text style={styles.proofLabel}>
|
||||
{dare.proof_type === "photo" ? "Photo proof required" : "Audio proof required"}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{dare.proof_media_id && dare.proof_type !== "none" ? (
|
||||
<DareProofView proofMediaId={dare.proof_media_id} proofType={dare.proof_type} />
|
||||
) : null}
|
||||
|
||||
{deadline ? (
|
||||
<View style={styles.deadlineRow}>
|
||||
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
|
||||
@@ -157,6 +175,13 @@ const styles = StyleSheet.create({
|
||||
fontStyle: "italic",
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
proofRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
proofLabel: { ...typography.caption1, color: ACCENT, fontWeight: "600" },
|
||||
deadlineRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator, Image, Modal, Platform, Pressable, StyleSheet, Text, View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useAudioRecorder } from "../../../hooks/useAudioRecorder";
|
||||
import { uploadMedia } from "../../../lib/mediaApi";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import type { SentDare } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
const MAX_AUDIO_SECONDS = 60;
|
||||
|
||||
export interface DareProofSheetProps {
|
||||
/** The active incoming dare being submitted; null closes the sheet. */
|
||||
dare: SentDare | null;
|
||||
onClose: () => void;
|
||||
/** Uploads have already happened; the id is ready to attach to the submit call. */
|
||||
onSubmit: (dareId: string, proofMediaId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function imageMime(uri: string): string {
|
||||
const ext = uri.split(".").pop()?.toLowerCase() ?? "jpg";
|
||||
if (ext === "png") return "image/png";
|
||||
if (ext === "webp") return "image/webp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
/** expo-av records .m4a on both platforms; web lands on webm. */
|
||||
function audioMime(uri: string): string {
|
||||
const ext = uri.split(".").pop()?.toLowerCase() ?? "m4a";
|
||||
if (ext === "webm") return "audio/webm";
|
||||
if (ext === "wav") return "audio/wav";
|
||||
if (ext === "mp3") return "audio/mpeg";
|
||||
if (ext === "caf") return "audio/x-caf";
|
||||
return "audio/mp4";
|
||||
}
|
||||
|
||||
export function DareProofSheet({ dare, onClose, onSubmit }: DareProofSheetProps) {
|
||||
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const recorder = useAudioRecorder({ maxDurationSeconds: MAX_AUDIO_SECONDS });
|
||||
const visible = dare !== null;
|
||||
const mode = dare?.proof_type ?? "none";
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setPhotoUri(null);
|
||||
setError(null);
|
||||
setSending(false);
|
||||
recorder.resetRecording();
|
||||
// Reset only when a new dare opens the sheet, not on recorder identity churn.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, dare?.id]);
|
||||
|
||||
const pickPhoto = useCallback(async (fromCamera: boolean) => {
|
||||
setError(null);
|
||||
if (fromCamera) {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
setError("Camera access is needed to take a proof photo.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = fromCamera
|
||||
? await ImagePicker.launchCameraAsync({ allowsEditing: false, quality: 0.7 })
|
||||
: await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: false,
|
||||
quality: 0.7,
|
||||
});
|
||||
if (!result.canceled) setPhotoUri(result.assets[0].uri);
|
||||
}, []);
|
||||
|
||||
const proofUri = mode === "photo" ? photoUri : recorder.recordingUri;
|
||||
const canSend = proofUri !== null && !sending && recorder.state !== "recording";
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!dare || !proofUri) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const mimeType = mode === "photo" ? imageMime(proofUri) : audioMime(proofUri);
|
||||
const upload = await uploadMedia(proofUri, { kind: "dare_proof", mimeType });
|
||||
await onSubmit(dare.id, upload.media.id);
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Could not send your proof. Check your connection and try again.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [dare, proofUri, mode, onSubmit, onClose]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.handle} />
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{mode === "photo" ? "Photo proof" : "Audio proof"}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} disabled={sending}>
|
||||
<Ionicons name="close" size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subtitle}>
|
||||
{mode === "photo"
|
||||
? "Your partner asked for a photo to prove this one."
|
||||
: "Your partner asked for a voice note to prove this one."}
|
||||
</Text>
|
||||
|
||||
{mode === "photo" ? (
|
||||
<View style={styles.captureArea}>
|
||||
{photoUri ? (
|
||||
<Image source={{ uri: photoUri }} style={styles.preview} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<Ionicons name="camera-outline" size={32} color={colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.captureButtons}>
|
||||
{Platform.OS !== "web" ? (
|
||||
<Pressable style={styles.captureButton} onPress={() => void pickPhoto(true)}>
|
||||
<Ionicons name="camera" size={16} color={ACCENT} />
|
||||
<Text style={styles.captureButtonText}>Take photo</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.captureButton} onPress={() => void pickPhoto(false)}>
|
||||
<Ionicons name="images-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.captureButtonText}>
|
||||
{photoUri ? "Choose another" : "Choose photo"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.captureArea}>
|
||||
<Pressable
|
||||
style={[styles.recordButton, recorder.state === "recording" && styles.recordButtonActive]}
|
||||
onPress={() => {
|
||||
if (recorder.state === "recording") void recorder.stopRecording();
|
||||
else void recorder.startRecording();
|
||||
}}
|
||||
disabled={sending}
|
||||
>
|
||||
<Ionicons
|
||||
name={recorder.state === "recording" ? "stop" : "mic"}
|
||||
size={28}
|
||||
color={recorder.state === "recording" ? colors.background : ACCENT}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={styles.recordStatus}>
|
||||
{recorder.state === "recording"
|
||||
? `Recording… ${recorder.durationSeconds}s`
|
||||
: recorder.recordingUri
|
||||
? `Recorded ${recorder.durationSeconds}s — tap the mic to redo`
|
||||
: `Tap to record (up to ${MAX_AUDIO_SECONDS}s)`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Pressable
|
||||
onPress={() => void handleSend()}
|
||||
disabled={!canSend}
|
||||
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator size="small" color={colors.background} />
|
||||
) : (
|
||||
<Text style={styles.sendButtonText}>Send proof & finish dare</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" },
|
||||
sheet: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: radius.xxl,
|
||||
borderTopRightRadius: radius.xxl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg,
|
||||
},
|
||||
handle: {
|
||||
width: 36, height: 4, borderRadius: radius.full,
|
||||
backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
title: { ...typography.title3, color: colors.text },
|
||||
subtitle: { ...typography.subhead, color: colors.textSecondary, marginBottom: spacing.lg },
|
||||
captureArea: { alignItems: "center", gap: spacing.md, marginBottom: spacing.lg },
|
||||
preview: {
|
||||
width: "100%", height: 220,
|
||||
borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border,
|
||||
},
|
||||
placeholder: {
|
||||
width: "100%", height: 220,
|
||||
borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
alignItems: "center", justifyContent: "center",
|
||||
},
|
||||
captureButtons: { flexDirection: "row", gap: spacing.sm },
|
||||
captureButton: {
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT,
|
||||
},
|
||||
captureButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
|
||||
recordButton: {
|
||||
width: 72, height: 72, borderRadius: radius.full,
|
||||
borderWidth: 2, borderColor: ACCENT,
|
||||
alignItems: "center", justifyContent: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
recordButtonActive: { backgroundColor: ACCENT },
|
||||
recordStatus: { ...typography.footnote, color: colors.textSecondary },
|
||||
error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md },
|
||||
sendButton: {
|
||||
backgroundColor: ACCENT,
|
||||
borderRadius: radius.full,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 50,
|
||||
},
|
||||
sendButtonDisabled: { opacity: 0.4 },
|
||||
sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" },
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Audio } from "expo-av";
|
||||
import { getMediaUrl } from "../../../lib/mediaApi";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import type { DareProofType } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
export interface DareProofViewProps {
|
||||
proofMediaId: string;
|
||||
proofType: DareProofType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the attached proof inline on a dare card: the photo itself, or a
|
||||
* play button for audio. Signed URLs are short-lived, so the URL is resolved
|
||||
* lazily on first render (photo) or first play (audio).
|
||||
*/
|
||||
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [sound, setSound] = useState<Audio.Sound | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [loadingAudio, setLoadingAudio] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (proofType !== "photo") return;
|
||||
let cancelled = false;
|
||||
getMediaUrl(proofMediaId)
|
||||
.then((result) => { if (!cancelled) setUrl(result.url); })
|
||||
.catch(() => { if (!cancelled) setFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [proofMediaId, proofType]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (sound) void sound.unloadAsync(); };
|
||||
}, [sound]);
|
||||
|
||||
const togglePlayback = useCallback(async () => {
|
||||
try {
|
||||
if (sound) {
|
||||
const status = await sound.getStatusAsync();
|
||||
if (status.isLoaded && status.isPlaying) {
|
||||
await sound.pauseAsync();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
await sound.replayAsync();
|
||||
setPlaying(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setLoadingAudio(true);
|
||||
const { url: signedUrl } = await getMediaUrl(proofMediaId);
|
||||
const created = await Audio.Sound.createAsync(
|
||||
{ uri: signedUrl },
|
||||
{ shouldPlay: true },
|
||||
(status) => {
|
||||
if (status.isLoaded && status.didJustFinish) setPlaying(false);
|
||||
},
|
||||
);
|
||||
setSound(created.sound);
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoadingAudio(false);
|
||||
}
|
||||
}, [sound, proofMediaId]);
|
||||
|
||||
if (failed) {
|
||||
return <Text style={styles.failed}>Proof could not be loaded.</Text>;
|
||||
}
|
||||
|
||||
if (proofType === "photo") {
|
||||
return url ? (
|
||||
<Image source={{ uri: url }} style={styles.photo} resizeMode="cover" />
|
||||
) : (
|
||||
<ActivityIndicator size="small" color={ACCENT} style={styles.loader} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={styles.audioRow} onPress={() => void togglePlayback()} disabled={loadingAudio}>
|
||||
{loadingAudio ? (
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
) : (
|
||||
<Ionicons name={playing ? "pause-circle" : "play-circle"} size={28} color={ACCENT} />
|
||||
)}
|
||||
<Text style={styles.audioLabel}>{playing ? "Playing proof…" : "Play audio proof"}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
photo: {
|
||||
width: "100%", height: 180, marginTop: spacing.sm,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
||||
},
|
||||
loader: { marginTop: spacing.sm, alignSelf: "flex-start" },
|
||||
audioRow: {
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
audioLabel: { ...typography.subhead, color: colors.text, fontWeight: "600" },
|
||||
failed: { ...typography.footnote, color: colors.textTertiary, marginTop: spacing.sm },
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types";
|
||||
import { DURATION_OPTIONS, PROOF_OPTIONS, type DareItem, type DarePack, type DareProofType, type SendDarePayload } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
@@ -26,6 +26,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [customText, setCustomText] = useState("");
|
||||
const [durationHours, setDurationHours] = useState<number | null>(24);
|
||||
const [proofType, setProofType] = useState<DareProofType>("none");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
@@ -35,6 +36,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
|
||||
setSelectedId(null);
|
||||
setCustomText("");
|
||||
setNotes("");
|
||||
setProofType("none");
|
||||
daresApi
|
||||
.packDares(pack.id)
|
||||
.then((result) => setDares(result.dares))
|
||||
@@ -52,10 +54,11 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
|
||||
? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours }
|
||||
: { dare_id: selectedId!, duration_hours: durationHours };
|
||||
if (notes.trim()) payload.sender_notes = notes.trim();
|
||||
if (proofType !== "none") payload.proof_type = proofType;
|
||||
const sent = await onSend(payload);
|
||||
setSending(false);
|
||||
if (sent) onClose();
|
||||
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]);
|
||||
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, proofType, notes, onSend, onClose]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
@@ -135,6 +138,29 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionLabel}>REQUIRE PROOF</Text>
|
||||
<View style={styles.durationRow}>
|
||||
{PROOF_OPTIONS.map((option) => {
|
||||
const selected = option.value === proofType;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => setProofType(option.value)}
|
||||
style={[styles.chip, styles.proofChip, selected && styles.chipSelected]}
|
||||
>
|
||||
<Ionicons
|
||||
name={option.icon as keyof typeof Ionicons.glyphMap}
|
||||
size={14}
|
||||
color={selected ? ACCENT : colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionLabel}>ADD A NOTE</Text>
|
||||
<TextInput
|
||||
style={styles.notesInput}
|
||||
@@ -229,6 +255,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2,
|
||||
borderRadius: radius.full, borderWidth: 1, borderColor: colors.border,
|
||||
},
|
||||
proofChip: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
|
||||
chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" },
|
||||
chipText: { ...typography.footnote, color: colors.textSecondary },
|
||||
chipTextSelected: { color: ACCENT, fontWeight: "600" },
|
||||
|
||||
@@ -4,3 +4,7 @@ export { DarePackGrid } from "./DarePackGrid";
|
||||
export type { DarePackGridProps } from "./DarePackGrid";
|
||||
export { SendDareSheet } from "./SendDareSheet";
|
||||
export type { SendDareSheetProps } from "./SendDareSheet";
|
||||
export { DareProofSheet } from "./DareProofSheet";
|
||||
export type { DareProofSheetProps } from "./DareProofSheet";
|
||||
export { DareProofView } from "./DareProofView";
|
||||
export type { DareProofViewProps } from "./DareProofView";
|
||||
@@ -30,7 +30,7 @@ export interface UseDaresReturn {
|
||||
refresh: () => Promise<void>;
|
||||
send: (payload: SendDarePayload) => Promise<boolean>;
|
||||
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
|
||||
submit: (dareId: string) => Promise<void>;
|
||||
submit: (dareId: string, proofMediaId?: string) => Promise<void>;
|
||||
complete: (dareId: string) => Promise<void>;
|
||||
cancel: (dareId: string) => Promise<void>;
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresRet
|
||||
refresh,
|
||||
send,
|
||||
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
|
||||
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
|
||||
submit: useCallback((dareId, proofMediaId) => act(dareId, () => daresApi.submit(dareId, proofMediaId)), [act]),
|
||||
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
|
||||
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export type DareStatus =
|
||||
| "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled";
|
||||
|
||||
/** Sender-chosen proof of completion; "none" means a plain "I did it" suffices. */
|
||||
export type DareProofType = "none" | "photo" | "audio";
|
||||
|
||||
export interface DarePack {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,6 +39,8 @@ export interface SentDare {
|
||||
direction: "incoming" | "outgoing";
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
proof_type: DareProofType;
|
||||
proof_media_id: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
@@ -70,8 +75,15 @@ export interface SendDarePayload {
|
||||
custom_dare_intensity?: number;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
proof_type?: DareProofType;
|
||||
}
|
||||
|
||||
export const PROOF_OPTIONS: { label: string; value: DareProofType; icon: string }[] = [
|
||||
{ label: "No proof", value: "none", icon: "checkmark-circle-outline" },
|
||||
{ label: "Photo", value: "photo", icon: "camera-outline" },
|
||||
{ label: "Audio", value: "audio", icon: "mic-outline" },
|
||||
];
|
||||
|
||||
export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [
|
||||
{ label: "1 hour", hours: 1 },
|
||||
{ label: "6 hours", hours: 6 },
|
||||
|
||||
@@ -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();
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -100,13 +100,17 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
|
||||
quality: 0.8,
|
||||
exif: false,
|
||||
});
|
||||
} else {
|
||||
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({
|
||||
} else {
|
||||
// Android's system photo picker grants the selected file directly, so
|
||||
// selecting an avatar does not need broad media-library access.
|
||||
if (Platform.OS !== 'android') {
|
||||
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({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
|
||||
@@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
|
||||
export function useMediaSaver() {
|
||||
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') => {
|
||||
try {
|
||||
|
||||
@@ -272,6 +272,15 @@ export const Events = {
|
||||
|
||||
// Invite events
|
||||
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
|
||||
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;
|
||||
}
|
||||
@@ -13,7 +13,11 @@ export const daresApi = {
|
||||
send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload),
|
||||
respond: (dareId: string, action: "accept" | "decline") =>
|
||||
apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }),
|
||||
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`),
|
||||
submit: (dareId: string, proofMediaId?: string) =>
|
||||
apiClient.post<{ dare: SentDare }>(
|
||||
`${dare(dareId)}/submit`,
|
||||
proofMediaId ? { proof_media_id: proofMediaId } : undefined,
|
||||
),
|
||||
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
|
||||
cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`),
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { ApiError, apiClient, authenticatedFetch } from './apiClient';
|
||||
|
||||
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback';
|
||||
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback' | 'dare_proof';
|
||||
export interface UploadMediaOptions { kind: MediaKind; mimeType: string; questionId?: string; matchId?: string }
|
||||
export interface MediaUploadResponse { reference: string; media: { id: string; mime_type: string; byte_size: number }; message?: Record<string, unknown> }
|
||||
|
||||
|
||||
@@ -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 { useStreakStore } = require("./streakStore");
|
||||
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) {
|
||||
console.log("[Auth] Session rejected by API, signing out");
|
||||
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();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
useQuizStore.getState().clearQuiz();
|
||||
await authClient.auth.signOut();
|
||||
return;
|
||||
}
|
||||
@@ -144,7 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isLoading: false,
|
||||
});
|
||||
// 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();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
@@ -152,6 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
useQuizStore.getState().clearQuiz();
|
||||
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
@@ -176,7 +179,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
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();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
@@ -184,6 +187,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
useQuizStore.getState().clearQuiz();
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
clearBadge();
|
||||
|
||||
@@ -11,3 +11,4 @@ export { useNotificationPreferencesStore } from "./notificationPreferencesStore"
|
||||
export type { NotificationPreferences } from "./notificationPreferencesStore";
|
||||
export { useStreakStore } 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>
|
||||
</dict>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>43</string>
|
||||
<string>45</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.5</string>
|
||||
<string>1.0.6</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
+12
-14
@@ -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_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
# Install dependencies from the committed monorepo lockfile.
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
|
||||
# Build the web workspace from the repository root.
|
||||
RUN npm run build -w @sauci/web
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
@@ -37,10 +34,11 @@ RUN apk add --no-cache curl
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Next preserves the workspace path in standalone output. Keep that layout so
|
||||
# the generated server can resolve its .next directory and public assets.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
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
|
||||
|
||||
@@ -49,4 +47,4 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
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} />
|
||||
}
|
||||
@@ -129,9 +129,12 @@ export default function PrivacyPolicy() {
|
||||
<li><strong>Portability:</strong> Request a copy of your data in a portable format.</li>
|
||||
<li><strong>Opt-out:</strong> Disable push notifications through your device settings.</li>
|
||||
</ul>
|
||||
<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>.
|
||||
</p>
|
||||
<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>.
|
||||
</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>
|
||||
@@ -174,9 +177,10 @@ export default function PrivacyPolicy() {
|
||||
<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>
|
||||
<div className="flex gap-6 text-sm text-white/40">
|
||||
<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="/" className="hover:text-white transition-colors">Home</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,9 +173,10 @@ export default function RedeemPage() {
|
||||
<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>
|
||||
<div className="flex gap-6 text-sm text-white/40">
|
||||
<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="/" className="hover:text-white transition-colors">Home</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,9 +235,10 @@ export default function TermsAndConditions() {
|
||||
<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>
|
||||
<div className="flex gap-6 text-sm text-white/40">
|
||||
<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="/" className="hover:text-white transition-colors">Home</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,15 @@ export default function Footer() {
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-6">
|
||||
<Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
<Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||
Privacy Policy
|
||||
</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">
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
<a href="mailto:support@sauci.app" className="text-white/60 hover:text-white transition-colors text-sm">
|
||||
Contact
|
||||
</a>
|
||||
|
||||
@@ -2,9 +2,9 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- 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()
|
||||
}
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user