Author SHA1 Message Date
jjbruntonandClaude Fable 5 504eddaf68 feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare,
catalogue or custom. The recipient captures and uploads the proof as a
new couple-scoped 'dare_proof' media kind and attaches it on submit;
the server enforces ownership, kind, couple, and MIME-type match.
The proof renders inline on the dare card for the sender to review
before confirming completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:36:11 +01:00
jjbruntonandClaude Fable 5 f22d4a3649 Merge quiz question variety into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:22:10 +01:00
jjbruntonandClaude Fable 5 45d24911fb feat(quiz): variety in quiz question selection
Expand the seeded pool from 12 to 36 questions (migration 0020) and
select 10 per session preferring questions the couple has never seen,
then least recently used, with random tiebreaks, so repeat quizzes stop
serving the same set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:55 +01:00
jjbruntonandClaude Fable 5 74c1acfe2d Merge couples quiz with shareable results card into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:11:47 +01:00
jjbrunton 23db4ed951 Merge branch 'staging' into task/quiz-viral 2026-08-30 18:10:24 +01:00
jjbrunton ccd3f03437 Merge invite funnel join links into staging 2026-08-30 18:09:54 +01:00
jjbruntonandClaude Fable 5 6523ddeb1c feat(quiz): couples quiz with shareable watermarked results card
Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:09:19 +01:00
jjbruntonandClaude Fable 5 4f9950f21b feat(invite-funnel): set real Android app signing cert fingerprint
Replace the assetlinks.json placeholder with the Play App Signing key
SHA-256 certificate fingerprint so Android App Links for
https://sauci.app/join/* can verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:06:42 +01:00
jjbruntonandClaude Fable 5 d48b44baa8 feat(invite-funnel): add web join page and mobile deep-link hand-off
Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:58:43 +01:00
jjbrunton 5564f9bbab Merge iOS CocoaPods lock sync into staging 2026-08-30 17:54:38 +01:00
jjbrunton c972b1c236 fix(ios): synchronize CocoaPods lock checksums 2026-08-30 17:54:30 +01:00
jjbrunton 30aa7bc386 Merge iOS CocoaPods UTF-8 fix into staging 2026-08-30 17:35:30 +01:00
jjbrunton 8bbb0e8cea fix(dev-local): set UTF-8 for native builds 2026-08-30 17:35:18 +01:00
jjbrunton fbdb2a8fff Merge task/agent-fast-lane into staging 2026-08-30 17:34:04 +01:00
jjbrunton bdfe8b7171 perf(agents): streamline Terra execution
Keep Sol responsible for scope, steering, and final acceptance while assigning one continuous discovery, implementation, testing, and iteration unit to Terra. Route additional reviewers and runtime verification by risk and avoid redundant verification passes.
2026-08-30 17:33:48 +01:00
jjbrunton 8d9a3f4f1c Merge local stack single-command fix into staging 2026-08-30 17:26:21 +01:00
jjbrunton 476e4c845f fix(dev-local): harden one-command startup 2026-08-30 17:25:30 +01:00
jjbrunton 3d53d001b7 Merge task/android-photo-picker into main 2026-08-30 17:16:08 +01:00
jjbrunton 8658fc38fe fix(android): use system photo picker
Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
2026-08-30 17:15:57 +01:00
jjbrunton 24de9af4cd fix(web): add account deletion page (#16)
Add the public deletion resource and repair the monorepo web container build contract.
2026-08-30 16:23:30 +01:00
jjbrunton 5e581efe51 Merge web container fix into staging 2026-08-30 16:19:20 +01:00
jjbrunton 12c6b8bcc9 fix(web): build from monorepo context 2026-08-30 16:18:55 +01:00
jjbrunton 599be22eb1 Merge task/google-account-deletion into staging 2026-08-30 16:09:59 +01:00
jjbrunton 9514835bf2 fix(web): add account deletion page 2026-08-30 16:07:19 +01:00
jjbrunton 65bd1bbd6b chore(release): record iOS build 45
chore(release): record iOS build 45
2026-08-30 10:51:43 +01:00
jjbrunton e0813ba5a3 docs(release): verify iOS extension versions
docs(release): verify iOS extension versions
2026-08-30 10:43:45 +01:00
jjbrunton f7c686d5cf docs(release): verify iOS extension versions 2026-08-30 10:39:33 +01:00
jjbrunton 314664ecd6 chore(ios): record v1.0.6 build 45
chore(ios): record v1.0.6 build 45
2026-08-30 10:37:43 +01:00
jjbrunton 11f89b1345 chore(ios): record v1.0.6 build 45 2026-08-30 10:33:16 +01:00
jjbrunton 2481956c11 chore(agents): prohibit em dashes 2026-08-30 10:17:37 +01:00
jjbrunton 8804e121b6 chore(release): promote iOS compliance fix
chore(release): promote iOS compliance fix
2026-08-30 10:15:27 +01:00
jjbrunton 7b3de48dbd Merge pull request #11 from jjbrunton/task/ios-export-compliance
chore(ios): declare exempt encryption
2026-08-30 10:07:24 +01:00
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
116 changed files with 5632 additions and 399 deletions

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`.
+9 -7
View File
@@ -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.
"""
+6 -2
View File
@@ -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.
+20 -11
View File
@@ -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.
+6
View File
@@ -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
+13 -3
View File
@@ -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
+65
View File
@@ -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;
+16
View File
@@ -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'));
+14
View File
@@ -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
}
]
}
+4
View File
@@ -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);
+1
View File
@@ -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';
+76 -8
View File
@@ -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> {
+16 -1
View File
@@ -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) {
+2 -2
View File
@@ -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);
+2 -1
View File
@@ -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 -1
View File
@@ -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 {
+389
View File
@@ -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);
}
}
+66
View File
@@ -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);
});
}
+51
View File
@@ -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;
+41
View File
@@ -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 };
}
+64
View File
@@ -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[];
}
+3
View File
@@ -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,
+52
View File
@@ -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' });
});
+20 -2
View File
@@ -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');
+250
View File
@@ -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);
});
});
+144
View File
@@ -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();
});
});
+56
View File
@@ -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);
});
});
+2 -2
View File
@@ -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"/>
+30 -3
View File
@@ -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": {
+4 -1
View File
@@ -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
+155 -4
View File
@@ -20,14 +20,21 @@ import { useAuthStore } from "../../src/store";
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 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,6 +43,64 @@ export default function PairingScreen() {
}
}, [couple, partner]);
// One-time prefill: apply a code carried by a join link (route param), then
// a stashed code from before sign-in, and only otherwise offer a clipboard
// match. Never auto-submits; the user still taps "Join Partner".
useEffect(() => {
if (couple) return; // only relevant to the unpaired join flow
if (prefillAttempted) return;
let cancelled = false;
const applyPrefill = async (code: string) => {
if (cancelled) return;
setInviteCode(code);
setWasPrefilled(true);
Events.pairingCodePrefilled();
};
(async () => {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const stashedCode = await getPendingInviteCode();
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const clipboardCode = await checkClipboardForInviteCode();
if (clipboardCode && !cancelled) {
setClipboardOfferCode(clipboardCode);
}
if (!cancelled) setPrefillAttempted(true);
})();
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
setInviteCode(clipboardOfferCode);
setWasPrefilled(true);
Events.inviteLinkOpened("clipboard");
Events.pairingCodePrefilled();
setClipboardOfferCode(null);
};
const dismissClipboardOffer = () => {
setClipboardOfferCode(null);
};
// Poll the standalone API while waiting for a partner. Realtime delivery can
// be added later without coupling product data back to Supabase.
useEffect(() => {
@@ -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 () => {
@@ -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,
+3 -230
View File
@@ -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";
import { QuizScreen } from "../../src/features/quiz";
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>
);
export default function QuizRoute() {
return <QuizScreen />;
}
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,
},
});
+19
View File
@@ -46,6 +46,9 @@ import { useAuthStore } from "../src/store";
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);
+43
View File
@@ -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)" />;
+4 -4
View File
@@ -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";
+4 -2
View File
@@ -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';
@@ -46,6 +46,7 @@ describe('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 () => {
@@ -180,8 +181,6 @@ describe('useMediaSaver', () => {
});
it('sets saving to true during operation', async () => {
let savingDuringOperation = false;
(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,
@@ -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,
+17 -2
View File
@@ -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]),
};
+12
View File
@@ -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,
};
}
+4
View File
@@ -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;
}
+58
View File
@@ -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;
}
+4
View File
@@ -101,11 +101,15 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
exif: false,
});
} 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,
+3 -1
View File
@@ -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 {
+9
View File
@@ -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;
}
+5 -1
View File
@@ -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`),
};
+76
View File
@@ -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 };
}
+1 -1
View File
@@ -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> }
+37
View File
@@ -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);
}
}
+8 -4
View File
@@ -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();
+1
View File
@@ -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";
+164
View File
@@ -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,
}));
},
}));
+2 -2
View File
@@ -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>
+10 -12
View File
@@ -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 ./
# Install dependencies from the committed monorepo lockfile.
COPY . .
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# 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"]
+19
View File
@@ -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')
})
})
+89
View File
@@ -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')
})
})
+153
View File
@@ -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&apos;ve been invited to Sauci</h1>
<p className="text-white/60 mb-10">
Open the app to pair up instantly. If it doesn&apos;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&apos;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>
)
}
+11
View File
@@ -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} />
}
+4
View File
@@ -132,6 +132,9 @@ export default function PrivacyPolicy() {
<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>
@@ -176,6 +179,7 @@ export default function PrivacyPolicy() {
<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="/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>
+1
View File
@@ -175,6 +175,7 @@ export default function RedeemPage() {
<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="/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>
+1
View File
@@ -237,6 +237,7 @@ export default function TermsAndConditions() {
<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="/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>
+3
View File
@@ -27,6 +27,9 @@ export default function Footer() {
<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>
+2 -2
View File
@@ -3,8 +3,8 @@ version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile
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}
+15
View File
@@ -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)}`
}
+24
View File
@@ -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')
})
})
+15
View File
@@ -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