mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9c9c2e15 | ||
|
|
4a57f31615 | ||
|
|
1c7084c033 | ||
|
|
0dfe4435b0 | ||
|
|
5ac4843095 | ||
|
|
8e48467414 | ||
|
|
1fed348072 | ||
|
|
a1b62c3b47 | ||
|
|
4415dcf29f | ||
|
|
95162d1889 | ||
|
|
dd1a47ecfa | ||
|
|
a02e9d531b | ||
|
|
49a9cd270f | ||
|
|
077f5433e1 | ||
|
|
ade2c42d51 | ||
|
|
daefdc6da9 | ||
|
|
029a90b338 | ||
|
|
bb0c712ede | ||
|
|
1c4ec2b412 | ||
|
|
21006cd041 | ||
|
|
f9481054de | ||
|
|
922066f492 | ||
|
|
ff2cf09170 | ||
|
|
402c027b14 | ||
|
|
1f5a939c3f | ||
|
|
ea038c9863 | ||
|
|
a431adad89 | ||
|
|
860986f900 |
No files matched your search
@@ -0,0 +1,328 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"project": "Sauci Private Mutual Discovery, OTA-first delivery plan",
|
||||||
|
"overview": "Evolve Sauci into a private mutual-discovery journey for adult couples: choose a mood and private shared comfort ceiling, answer a short set privately, reveal only mutual interest, then choose an optional next action. Deliver independent client slices through Expo OTA where the installed binary is compatible, while keeping session privacy, consent state, catalogue safety, API, data, and operational changes as separately gated work.",
|
||||||
|
"goals": [
|
||||||
|
"Make the first useful experience a short, mood-led Quick Spark session rather than an open-ended question feed.",
|
||||||
|
"Reveal only mutual interest or both-partner responses and never reveal an unmatched preference or which partner selected a lower comfort ceiling.",
|
||||||
|
"Let a mutual match lead to an optional talk, save, or shared action without turning either partner into an assigned recipient.",
|
||||||
|
"Make defer, decline, a softer alternative, and withdrawal explicit, reversible where appropriate, and free of penalties.",
|
||||||
|
"Keep developer-authored content non-graphic, reviewed, and eligible before it can reach a customer.",
|
||||||
|
"Maximize JavaScript and bundled-asset work that can ship through OTA after live compatibility is proven."
|
||||||
|
],
|
||||||
|
"nonGoals": [
|
||||||
|
"No production deployment, OTA publish, store submission, migration execution, catalogue activation, or provider write is authorized by this PRD.",
|
||||||
|
"No explicit sexual instructions, content intended for sexual gratification, or use of UGC as a policy workaround.",
|
||||||
|
"No clinical relationship-repair service, coercion intervention, solo experience, points economy, or mandatory task assignment.",
|
||||||
|
"No disclosure of individual answers, individual comfort selections, intimate content, or prompt text through analytics or push notification previews.",
|
||||||
|
"No assumption that the legacy Supabase recommendation-function documentation describes the current standalone API implementation."
|
||||||
|
],
|
||||||
|
"successMetrics": [
|
||||||
|
"Measure only aggregate privacy-safe events: session starts, two-partner completion, mutual matches, optional talk/save/action selections, skip/defer/decline/withdraw/report/block, and 7/28-day repeat sessions.",
|
||||||
|
"No analytics event contains question text, response text, audio, photo, individual answer, individual ceiling, or intimate match detail.",
|
||||||
|
"A paired couple can complete a reviewed Quick Spark session and see only its allowed shared outcome.",
|
||||||
|
"A user can stop, defer, decline, or withdraw without losing access or creating a partner-visible attribution of their private reason.",
|
||||||
|
"Every OTA-labelled story is released only after its platform/runtime canary and final native-surface review pass."
|
||||||
|
],
|
||||||
|
"openQuestions": [
|
||||||
|
"Which reviewed mood taxonomy and initial prompt set best fit the product positioning without crossing the cross-platform content ceiling?",
|
||||||
|
"Should saved mutual interests expire automatically, be archived manually, or both?",
|
||||||
|
"What exact participant-visible wording preserves a withdrawal without falsely implying that a partner declined?",
|
||||||
|
"What age assurance, reporting, blocking, moderation ownership, rate limits, and incident process are effective in production for all relevant UGC surfaces?",
|
||||||
|
"Does the public Android build embed the same XPREM app id, channel headers, certificate, and runtime 1.0.8 configuration as the inspected source?"
|
||||||
|
],
|
||||||
|
"stack": {
|
||||||
|
"framework": "Expo React Native with Expo Router and TypeScript",
|
||||||
|
"mobileDelivery": "Self-hosted XPREM implementing Expo Updates; iOS public source runtime is 1.0.8 and commit 3659cc8 records iOS build 49, but installed-build compatibility requires a live canary",
|
||||||
|
"backend": "Standalone Node HTTP API with PostgreSQL and Drizzle migrations",
|
||||||
|
"admin": "React/Vite content administration UI",
|
||||||
|
"auth": "Hosted Supabase Auth only; product data remains in the standalone API database",
|
||||||
|
"analytics": "Existing privacy-safe telemetry conventions, expanded only after event-schema review"
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"path": "/(app)/swipe",
|
||||||
|
"name": "Quick Spark entry and session",
|
||||||
|
"purpose": "Mood-led private short session, reusing the existing swipe feature while a dedicated route is evaluated."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/(app)/matches",
|
||||||
|
"name": "Mutual discovery outcomes",
|
||||||
|
"purpose": "Show a mutual result and route to existing conversation, saving, or a future shared-action flow."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/(app)/dares",
|
||||||
|
"name": "Existing dares",
|
||||||
|
"purpose": "Remain a separate sender-to-recipient challenge model; it must not be relabelled as a shared action."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/admin/questions and /admin/packs",
|
||||||
|
"name": "Question catalogue authoring",
|
||||||
|
"purpose": "Author, review, and activate only content whose enforced eligibility contract is proven."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uiNotes": [
|
||||||
|
"Use the existing Swipe/Match feature colour, rose-to-purple gradient, locked flame and heart icons, dark solid surfaces, and high-contrast accessible controls.",
|
||||||
|
"Quick Spark should feel finite and calm: mood selection, a short 5-8 prompt progress state, private-answer reassurance, and a restrained completion state.",
|
||||||
|
"Comfort selection must describe a shared ceiling, not a score to compare. Never render the partner's value, a lower-value hint, or copy that attributes the ceiling to either person.",
|
||||||
|
"Outcome actions must be equal-weight choices. Talk opens the existing match conversation; Save is not shown until it has durable server semantics; shared action is not shown until its domain contract exists.",
|
||||||
|
"Safety actions must be clear, non-judgmental, and available before commitment. Avoid gamification, streak pressure, countdown pressure, or punitive copy."
|
||||||
|
],
|
||||||
|
"dataModel": [
|
||||||
|
{
|
||||||
|
"entity": "mutual_discovery_sessions",
|
||||||
|
"fields": ["id", "couple_id", "created_at", "status", "mood", "shared_comfort_ceiling", "expires_at"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "mutual_discovery_session_participants",
|
||||||
|
"fields": ["session_id", "user_id", "private_comfort_selection", "completed_at", "withdrawn_at"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "shared_actions",
|
||||||
|
"fields": ["id", "couple_id", "match_id", "status", "created_by", "saved_at", "deferred_at", "withdrawn_at", "expires_at"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "shared_action_events",
|
||||||
|
"fields": ["id", "shared_action_id", "actor_id", "event_type", "created_at"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity": "catalogue_review",
|
||||||
|
"fields": ["content_id", "content_kind", "content_status", "reviewed_by", "reviewed_at", "review_reason"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"importFormat": {
|
||||||
|
"description": "No bulk import. Developer-authored prompts and packs use the existing audited admin authoring workflow and must remain unreviewed and ineligible until an editorial decision is recorded and server enforcement is verified.",
|
||||||
|
"example": {}
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
"Mutuality is server-calculated from couple-scoped inputs. A client must never infer mutuality from its own answer or a local cache.",
|
||||||
|
"The shared comfort ceiling is the minimum of two private selections, but neither private selection is returned to the partner or emitted in analytics.",
|
||||||
|
"A no produces no mutual match. Existing stored maybe semantics remain backwards compatible until a separately approved session contract changes them.",
|
||||||
|
"Existing `max_intensity` and pack selection gates are not a per-session private comfort protocol and must not be presented as one.",
|
||||||
|
"Current dares have sender and recipient roles. Reuse only a truthful optional handoff after the mutual shared-action domain exists; do not map shared action states onto dare states.",
|
||||||
|
"All API reads and writes enforce authenticated couple isolation server-side. Unauthorized or cross-couple identifiers return non-disclosing failures.",
|
||||||
|
"New developer-authored adult-oriented prompts cannot be activated until universal question and pack eligibility enforcement is proven in the active API path.",
|
||||||
|
"Push payloads remain generic and do not include question, answer, match, action, or intimate content text.",
|
||||||
|
"A new question interaction type, response payload, configuration shape, or match semantic updates docs/question-types.md and passes lint:harness in its implementation story.",
|
||||||
|
"OTA eligibility is assessed per final diff. JavaScript and assets are only OTA candidates when native code, dependencies, permissions, entitlements, config-plugin output, Expo SDK, ios/, android/, expo.version, and runtimeVersion remain unchanged."
|
||||||
|
],
|
||||||
|
"qualityGates": [
|
||||||
|
"npm run lint:harness",
|
||||||
|
"npm run verify:fast",
|
||||||
|
"npm run verify:full",
|
||||||
|
"Focused API route and integration tests for every changed domain",
|
||||||
|
"Focused mobile Jest tests, TypeScript check, lint, and observable iOS/Android simulator evidence for every changed mobile UI flow",
|
||||||
|
"Read-only OTA preflight and doctor checks for the targeted channel and platform before any separately authorized OTA publish",
|
||||||
|
"Live production compatibility and end-to-end read-back before a production release, including deployed API compatibility for any client change"
|
||||||
|
],
|
||||||
|
"stories": [
|
||||||
|
{
|
||||||
|
"id": "US-001",
|
||||||
|
"title": "Establish release and live-state gates for mutual discovery",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": [],
|
||||||
|
"description": "As a release owner, I want a verified compatibility record before labelling a client slice OTA-safe so that an update reaches the intended installed app without assuming source equals production.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 0, release readiness.",
|
||||||
|
"Deployment classification: operations and live-state verification, no OTA or store release in this story.",
|
||||||
|
"Record read-only evidence that public iOS source is version/runtime 1.0.8, commit 3659cc8 records build 49, and the production XPREM doctor serves v2 clients; separately record whether a real installed iOS build 49 receives a signed production manifest for runtime 1.0.8.",
|
||||||
|
"Record Android installed-build, XPREM header, certificate, channel, runtime, and manifest evidence independently; until then mark Android OTA eligibility unverified.",
|
||||||
|
"Map every deployed consumer for the proposed API contracts and identify backward-compatible response handling required before the first backend rollout.",
|
||||||
|
"Example: an installed iOS build reports runtime 1.0.8 and receives the expected signed production manifest, so a JS-only iOS Quick Spark diff may proceed to release review.",
|
||||||
|
"Negative case: source contains runtime 1.0.8 but the installed binary does not request or accept that manifest, so no OTA is published and a native/store remediation is planned."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-002",
|
||||||
|
"title": "Prove universal reviewed-question eligibility before activating new content",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-001"],
|
||||||
|
"description": "As a catalogue owner, I want the active recommendation and pack APIs to fail closed on review status so that a new Quick Spark prompt cannot become customer-visible before editorial approval.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 0, safety prerequisite.",
|
||||||
|
"Deployment classification: API/data contract work, no OTA path by itself.",
|
||||||
|
"Trace the active standalone API recommendation and pack endpoints, not only legacy Supabase migrations, and document the effective review-status behavior before changing it.",
|
||||||
|
"If enforcement is absent, implement the smallest server-side, migration-backed eligibility contract with admin authoring parity and preserve current allowed content availability.",
|
||||||
|
"Ensure a stale mobile client cannot retrieve or answer an unreviewed or archived developer-authored question through any recommendation, pack-detail, or answer route.",
|
||||||
|
"Example: an allowed question in an enabled pack remains selectable for a paired couple.",
|
||||||
|
"Negative case: an unreviewed question is returned by neither a direct pack request nor a recommendation, even when a stale client sends its id."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-003",
|
||||||
|
"title": "Add a Quick Spark mood entry shell",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-001", "US-002"],
|
||||||
|
"description": "As a paired user, I want to choose a gentle mood before a short private session so that the discovery experience feels intentional rather than like an endless feed.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 1, OTA-first client foundation.",
|
||||||
|
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||||
|
"Add a thin route entry and focused swipe-feature components for a reviewed fixed mood taxonomy, selected locally for this prototype with no new persistence contract.",
|
||||||
|
"Show paired, loading, no-pack, unpaired, and retry states using existing truthful states; do not promise mutual outcomes to an unpaired user whose answers remain sealed.",
|
||||||
|
"Provide stable accessibility labels or testIDs for mood choices and continue action.",
|
||||||
|
"Example: a paired user chooses 'playful' and continues into the existing question flow with a clear short-session explanation.",
|
||||||
|
"Negative case: an unpaired user cannot be shown copy implying that their private answer will match with a partner."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-004",
|
||||||
|
"title": "Present a finite Quick Spark card sequence",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-003"],
|
||||||
|
"description": "As a user in Quick Spark, I want a finite 5-8 prompt session with honest progress so that I can participate without open-ended pressure.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 1, OTA-first client foundation.",
|
||||||
|
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||||
|
"Compose the sequence only from already eligible questions returned by the current API and preserve existing answer, skip, upload, retry, and sealed-answer behavior.",
|
||||||
|
"Render progress as completed prompts out of the actual session length and make skip available without punitive copy or a lost-progress state.",
|
||||||
|
"Keep route files thin and add focused tests for sequence construction, progress, and early exit.",
|
||||||
|
"Example: a session with six eligible questions shows progress from 1 of 6 through 6 of 6 and ends in a completion state.",
|
||||||
|
"Negative case: with fewer than five eligible questions, the UI does not pad with ineligible or repeated prompts and instead gives a truthful fallback."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-005",
|
||||||
|
"title": "Add privacy-preserving Quick Spark completion and existing-talk handoff",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-004"],
|
||||||
|
"description": "As a user who finishes a short session, I want a clear private completion state and a way to talk about an existing mutual match so that the app supports choice without inventing a new server state.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 1, OTA-first client foundation.",
|
||||||
|
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||||
|
"Use only existing server-calculated match data and route Talk to the existing match conversation; do not persist a new Save or shared-action state in local storage as if it were couple-shared.",
|
||||||
|
"If no mutual result exists yet because the partner has not answered, show a neutral waiting state without exposing the current user's answer or implying partner rejection.",
|
||||||
|
"Make completion, Talk, and dismiss controls accessible and testable.",
|
||||||
|
"Example: both partners answer yes to an eligible swipe prompt and the completion outcome can open that existing match conversation.",
|
||||||
|
"Negative case: one partner answers yes and the other has not answered, so no prompt text, answer, or individual interest is disclosed."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-006",
|
||||||
|
"title": "Create the per-session private comfort schema and API contract",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-001", "US-002"],
|
||||||
|
"description": "As a couple, I want each partner to privately choose a comfort level that produces a shared ceiling so that selection is bounded without exposing who chose the lower level.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 2, server foundation.",
|
||||||
|
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||||
|
"Add a committed Drizzle migration and a standalone API contract for a couple-scoped session and one private selection per participant, with idempotency and explicit session lifecycle validation.",
|
||||||
|
"Calculate and return only a shared ceiling suitable for the caller. Do not return either participant's private selection to the other participant or analytics payloads.",
|
||||||
|
"Enforce authentication, couple isolation, participant membership, duplicate-submission handling, and non-disclosing errors for foreign session ids.",
|
||||||
|
"Example: selections 2 and 4 yield a shared ceiling of 2 while each partner can only retrieve the shared result.",
|
||||||
|
"Negative case: a user outside the couple cannot read, set, or infer any session or individual selection."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-007",
|
||||||
|
"title": "Apply shared comfort selection to server-side Quick Spark composition",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-006"],
|
||||||
|
"description": "As a session participant, I want the prompt set selected under our shared ceiling so that the content respects both private choices.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 2, server foundation.",
|
||||||
|
"Deployment classification: API deployment required; a later compatible mobile consumer can be OTA-delivered only after US-001 platform gates pass.",
|
||||||
|
"Add a couple-scoped session recommendation endpoint or extend a documented endpoint so the API filters reviewed, enabled, unanswered prompts by the calculated session ceiling.",
|
||||||
|
"Preserve existing recommendation behavior for non-session clients and document any backward-compatible response fields before rollout.",
|
||||||
|
"Ensure response ordering does not leak an excluded higher-intensity prompt or either participant's private selection.",
|
||||||
|
"Example: a shared ceiling of 2 returns only eligible intensity 1-2 reviewed prompts from enabled inventory.",
|
||||||
|
"Negative case: an intensity 3 prompt is not returned or inferable when one participant privately selected 2."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-008",
|
||||||
|
"title": "Wire private comfort selection and server-backed Quick Spark into mobile",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-004", "US-006", "US-007"],
|
||||||
|
"description": "As a paired user, I want to select my comfort privately and receive only the shared session outcome so that the client enforces the same privacy model as the API.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 2, client integration.",
|
||||||
|
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||||
|
"Replace prototype-only composition with the authenticated session API and handle loading, incomplete-partner, expired-session, retry, and non-disclosing failure states.",
|
||||||
|
"Render only the caller's choice before submission and only the returned shared ceiling afterwards. Clear user-scoped local state at sign-out.",
|
||||||
|
"Do not cache, log, or send individual comfort values to analytics beyond the server request needed to create the session.",
|
||||||
|
"Example: a user chooses a private level, resumes later, and sees a waiting state until both participants have selected without seeing the partner's value.",
|
||||||
|
"Negative case: a replayed response or API error cannot leave another account's session or private value visible after sign-out/sign-in."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-009",
|
||||||
|
"title": "Create a durable shared-action state machine",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-001"],
|
||||||
|
"description": "As a couple with a mutual match, I want a shared action record with neutral lifecycle states so that Talk, Save, Defer, softer alternative, and withdrawal are truthful and do not overload the existing dare model.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 3, server foundation.",
|
||||||
|
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||||
|
"Define and implement a committed migration, server-side state machine, actor permissions, expiry policy, and match linkage for shared actions independently of sent_dares.",
|
||||||
|
"Support the approved vocabulary for save, defer, decline, softer alternative, withdrawal, and completion only where their semantics are explicit; ensure a participant can withdraw without exposing a private reason.",
|
||||||
|
"Return generic, non-attributing partner-visible state copy and use generic push previews if notifications are later introduced.",
|
||||||
|
"Example: either member saves a mutual match for later and both can see the neutral saved state.",
|
||||||
|
"Negative case: a sender-to-recipient dare transition cannot be used to imply that a shared action was accepted, completed, or declined."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-010",
|
||||||
|
"title": "Expose couple-isolated shared-action API routes and tests",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-009"],
|
||||||
|
"description": "As a mobile client, I want a documented HTTP contract for shared actions so that the UI can offer optional choices without relying on client-only truth.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 3, server foundation.",
|
||||||
|
"Deployment classification: API deployment required; no OTA path by itself.",
|
||||||
|
"Provide versioned read and mutation routes with schema validation, idempotency where retries are possible, non-disclosing cross-couple failures, and backward-compatible match responses.",
|
||||||
|
"Test every legal and illegal state transition, both partner roles, withdrawn and expired behavior, and match ownership.",
|
||||||
|
"Document the participant-visible contract and any interaction-type or match-semantics change in the maintained documentation in the same implementation.",
|
||||||
|
"Example: either partner can defer a saved shared action and the API returns a neutral deferred representation to both.",
|
||||||
|
"Negative case: a caller cannot mutate, list, or learn the existence of an action outside their couple."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-011",
|
||||||
|
"title": "Add optional shared-action choices to mutual match outcomes",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-005", "US-010"],
|
||||||
|
"description": "As a couple viewing a mutual match, I want equal, optional Talk, Save, and shared-action choices so that mutual discovery becomes a real-world possibility without pressure.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 3, client integration.",
|
||||||
|
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||||
|
"Render shared-action UI only for server-confirmed mutual matches and use the shared-action API as the source of truth for save, defer, softer alternative, withdrawal, and completion state.",
|
||||||
|
"Keep Talk routed to existing match chat, keep existing Dares visually and semantically distinct, and make all action choices optional and dismissible.",
|
||||||
|
"Include accessible labels/testIDs and focused tests for loading, retry, stale response, and each safe state.",
|
||||||
|
"Example: a mutual match can be saved, then later shown as saved to both partners without exposing who initiated it.",
|
||||||
|
"Negative case: an unmatched question, a sealed solo answer, or a stale local match cannot create or display a shared action."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-012",
|
||||||
|
"title": "Add a reviewed Tell Me Privately session variant",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-002", "US-008"],
|
||||||
|
"description": "As a couple, I want a text or audio-led private response option with a both-partner reveal so that we can share a thought without an explicit content catalogue.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 4, controlled content expansion.",
|
||||||
|
"Deployment classification: question-type changes require shared contract, API, admin, database, and mobile deployment; it is not OTA-only. A client renderer after the deployed contract may be an OTA candidate subject to US-001 gates.",
|
||||||
|
"First determine whether existing text_answer and audio types can meet the approved experience without changing payload or match semantics; prefer configuration and reviewed content if they can.",
|
||||||
|
"If a new interaction type, payload, configuration, or match semantic is necessary, change the shared union, committed database constraint, API, admin authoring, mobile renderer, docs/question-types.md, and harness contract together.",
|
||||||
|
"Ensure both-partner reveal preserves existing response-summary privacy and does not put user-authored text or audio in analytics or push previews.",
|
||||||
|
"Example: two partners submit allowed text answers and see the existing both_answered match representation with a Talk option.",
|
||||||
|
"Negative case: one partner's private text or audio cannot be revealed, notified, or analytically recorded before the other required response exists."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-013",
|
||||||
|
"title": "Create privacy-safe measurement and release decision review",
|
||||||
|
"status": "open",
|
||||||
|
"dependsOn": ["US-008", "US-011", "US-012"],
|
||||||
|
"description": "As a product and safety owner, I want a reviewable aggregate measurement plan and staged release decision so that adoption never outruns consent, safety, or platform compliance.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Phase: 5, controlled rollout readiness.",
|
||||||
|
"Deployment classification: analytics/API/client work plus release operations; OTA eligibility is evaluated per final client diff and does not authorize publishing.",
|
||||||
|
"Define a typed event schema containing only aggregate category and lifecycle fields, with a test that rejects prompt text, answer text, media paths, private comfort values, and intimate match detail.",
|
||||||
|
"Set pre-agreed stop and review thresholds for reports, blocks, skips, defers, declines, withdrawals, and error rates; do not frame safety signals as conversion loss to suppress.",
|
||||||
|
"Require production read-back of deployed API compatibility, content review evidence, UGC moderation and incident controls, and platform-specific OTA canary before a separately authorized staged release.",
|
||||||
|
"Example: an aggregate session_completed event records mood category and completion state but no prompt or response content.",
|
||||||
|
"Negative case: a telemetry payload containing a response text field, media path, or individual comfort value fails validation and is not sent."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export interface CoupleRepository {
|
|||||||
getState(userId: string): Promise<CoupleStateResponse>;
|
getState(userId: string): Promise<CoupleStateResponse>;
|
||||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||||
|
cancelInvite(userId: string): Promise<void>;
|
||||||
cancel(userId: string): Promise<void>;
|
cancel(userId: string): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -268,6 +269,43 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<void> {
|
||||||
|
await transaction(this.pool, async (client) => {
|
||||||
|
// Lock in the same order as join(): the caller's profile first, then the
|
||||||
|
// couple. A concurrent join therefore either completes first and makes
|
||||||
|
// this a safe no-op error, or observes that the invite no longer exists.
|
||||||
|
const profile = await profileForUpdate(client, userId);
|
||||||
|
if (!profile.couple_id) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupleResult = await client.query<{ id: string }>(
|
||||||
|
'select id from couples where id = $1 for update',
|
||||||
|
[profile.couple_id],
|
||||||
|
);
|
||||||
|
if (!coupleResult.rows[0]) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await client.query<{ id: string }>(
|
||||||
|
'select id from profiles where couple_id = $1',
|
||||||
|
[profile.couple_id],
|
||||||
|
);
|
||||||
|
if (members.rows.length > 1) {
|
||||||
|
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
|
||||||
|
}
|
||||||
|
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point the couple has precisely the authenticated creator as a
|
||||||
|
// member. Removing it cannot delete paired data. Answers made before
|
||||||
|
// invite creation stay sealed; answers made while waiting are coupled
|
||||||
|
// and are deleted with the cancelled invite.
|
||||||
|
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await closeResolvedPool(this.pool, this.ownsPool);
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
|
|||||||
const result = await route(() => service.cancel(c.get('identity').id));
|
const result = await route(() => service.cancel(c.get('identity').id));
|
||||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/couple/invite', async (c) => {
|
||||||
|
const result = await route(() => service.cancelInvite(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -41,4 +41,9 @@ export class CoupleService {
|
|||||||
await this.repository.cancel(userId);
|
await this.repository.cancel(userId);
|
||||||
return { success: true, couple_id: null };
|
return { success: true, couple_id: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
|
||||||
|
await this.repository.cancelInvite(userId);
|
||||||
|
return { success: true, couple_id: null };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
|
|||||||
| 'invalid_invite_code'
|
| 'invalid_invite_code'
|
||||||
| 'couple_full'
|
| 'couple_full'
|
||||||
| 'not_paired'
|
| 'not_paired'
|
||||||
|
| 'invite_already_joined'
|
||||||
| 'invite_code_collision';
|
| 'invite_code_collision';
|
||||||
|
|
||||||
export class CoupleError extends Error {
|
export class CoupleError extends Error {
|
||||||
|
|||||||
@@ -68,6 +68,56 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
|||||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels a waiting invite only for its creator', async () => {
|
||||||
|
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
const pack = 'abababab-abab-4bab-8bab-abababababab';
|
||||||
|
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
|
||||||
|
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
|
||||||
|
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
|
||||||
|
await pool.query(
|
||||||
|
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
|
||||||
|
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
|
||||||
|
[randomUUID(), charlie, sealedQuestion],
|
||||||
|
);
|
||||||
|
await repository.create(charlie, coupleId, 'WAIT2345');
|
||||||
|
await pool.query(
|
||||||
|
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
|
||||||
|
[randomUUID(), charlie, waitingQuestion, coupleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.cancelInvite(charlie);
|
||||||
|
|
||||||
|
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
|
||||||
|
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
|
||||||
|
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
|
||||||
|
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never deletes a joined couple through the invite cancellation path', async () => {
|
||||||
|
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
|
await repository.create(alice, coupleId, 'SAFE2345');
|
||||||
|
await repository.join(bob, 'SAFE2345');
|
||||||
|
|
||||||
|
const before = await pool.query<{ id: string; couple_id: string | null }>(
|
||||||
|
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||||
|
[alice, bob],
|
||||||
|
);
|
||||||
|
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
|
||||||
|
code: 'invite_already_joined',
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await pool.query<{ id: string; couple_id: string | null }>(
|
||||||
|
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||||
|
[alice, bob],
|
||||||
|
);
|
||||||
|
expect(after.rows).toEqual(before.rows);
|
||||||
|
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
||||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
|
|||||||
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
||||||
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
||||||
import { CoupleService } from '../src/domains/couples/service.js';
|
import { CoupleService } from '../src/domains/couples/service.js';
|
||||||
|
import { CoupleError } from '../src/domains/couples/types.js';
|
||||||
|
|
||||||
const identity: AuthIdentity = {
|
const identity: AuthIdentity = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -25,6 +26,7 @@ function setup() {
|
|||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
created_at: '2026-08-27T00:00:00.000Z',
|
created_at: '2026-08-27T00:00:00.000Z',
|
||||||
})),
|
})),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
@@ -80,4 +82,28 @@ describe('couple routes', () => {
|
|||||||
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||||
|
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
|
||||||
|
expect(repository.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn a joined invite into relationship deletion', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
|
||||||
|
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
|
||||||
|
});
|
||||||
|
expect(repository.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
|
|||||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||||
create: vi.fn(async () => couple),
|
create: vi.fn(async () => couple),
|
||||||
join: vi.fn(async () => couple),
|
join: vi.fn(async () => couple),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
|
|||||||
expect(create).toHaveBeenCalledTimes(2);
|
expect(create).toHaveBeenCalledTimes(2);
|
||||||
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the invite-only cancellation operation for a waiting creator', async () => {
|
||||||
|
const repo = repository();
|
||||||
|
const service = new CoupleService(repo);
|
||||||
|
|
||||||
|
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
|
||||||
|
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
|
||||||
|
expect(repo.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -76,10 +76,11 @@ export default function AppLayout() {
|
|||||||
const segmentStrings = segments as string[];
|
const segmentStrings = segments as string[];
|
||||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||||
const isOnPairing = segmentStrings.includes("pairing");
|
const isOnPairing = segmentStrings.includes("pairing");
|
||||||
|
const isOnPaired = segmentStrings.includes("paired");
|
||||||
const isOnChat = segmentStrings.includes("chat");
|
const isOnChat = segmentStrings.includes("chat");
|
||||||
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
||||||
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
||||||
|
|
||||||
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -367,6 +368,7 @@ export default function AppLayout() {
|
|||||||
href: null, // Hide from tab bar
|
href: null, // Hide from tab bar
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen name="paired" options={{ href: null }} />
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="chat/[id]"
|
name="chat/[id]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { GradientBackground, GlassButton } from "../../src/components/ui";
|
||||||
|
import { colors, gradients, spacing, typography } from "../../src/theme";
|
||||||
|
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||||
|
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
|
||||||
|
import { Events } from "../../src/lib/analytics";
|
||||||
|
|
||||||
|
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
|
||||||
|
const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+";
|
||||||
|
|
||||||
|
export default function PairedScreen() {
|
||||||
|
const { user, couple, partner } = useAuthStore();
|
||||||
|
const { newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
|
||||||
|
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
|
||||||
|
const heading = useRef<Text>(null);
|
||||||
|
const role = rawRole === "inviter" ? "inviter" : "joiner";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchMatches(true).then((success) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadedSuccessfully(success);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [fetchMatches]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || !loadedSuccessfully || !user || !couple) return;
|
||||||
|
void markPairedUnlockSeen(user.id, couple.id);
|
||||||
|
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
|
||||||
|
|
||||||
|
const partnerName = partner?.name?.split(" ")[0] || "your partner";
|
||||||
|
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
|
||||||
|
const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); };
|
||||||
|
const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); };
|
||||||
|
if (!ready) {
|
||||||
|
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
||||||
|
}
|
||||||
|
if (!loadedSuccessfully) {
|
||||||
|
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||||
|
}
|
||||||
|
return <GradientBackground><View style={styles.container}>
|
||||||
|
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
||||||
|
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
|
||||||
|
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} shared discover{newMatchesCount === 1 ? "y" : "ies"} ready to explore</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See shared discoveries</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing is ready to compare yet. Answer a few questions and shared discoveries will appear here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
|
||||||
|
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
|
||||||
|
</View></GradientBackground>;
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} });
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Linking,
|
Linking,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
AccessibilityInfo,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
@@ -26,27 +27,45 @@ import { GradientBackground, GlassCard, GlassButton } from "../../src/components
|
|||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
||||||
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||||
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||||
|
import { ApiError } from "../../src/lib/apiClient";
|
||||||
|
import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen";
|
||||||
|
import { PAIRING_PRIVACY_SUMMARY } from "../../src/lib/pairingPrivacyCopy";
|
||||||
|
|
||||||
export default function PairingScreen() {
|
export default function PairingScreen() {
|
||||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
|
const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
|
||||||
const params = useLocalSearchParams<{ code?: string }>();
|
const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [wasPrefilled, setWasPrefilled] = useState(false);
|
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||||
|
const [joinError, setJoinError] = useState<string | null>(null);
|
||||||
|
const [isPollOffline, setIsPollOffline] = useState(false);
|
||||||
|
const [isIncomingConflictDismissed, setIsIncomingConflictDismissed] = useState(false);
|
||||||
|
|
||||||
// Redirect if already paired. Pairing claims both members' sealed answers and
|
// Redirect if already paired. Pairing claims both members' sealed answers and
|
||||||
// computes matches server-side, so the matches store must refetch here too:
|
// computes matches server-side, so the matches store must refetch here too:
|
||||||
// whichever partner lands on this redirect first would otherwise show stale,
|
// whichever partner lands on this redirect first would otherwise show stale,
|
||||||
// pre-pairing matches until some unrelated screen happened to refresh it.
|
// pre-pairing matches until some unrelated screen happened to refresh it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (couple && partner) {
|
if (!couple || !partner || !user) return;
|
||||||
void useMatchStore.getState().fetchMatches(true);
|
let cancelled = false;
|
||||||
router.replace("/(app)");
|
void (async () => {
|
||||||
}
|
if (typeof params.incomingCode === "string") {
|
||||||
}, [couple, partner]);
|
await clearPendingInviteCode(user.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = await hasSeenPairedUnlock(user.id, couple.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
router.replace(seen ? "/(app)" : "/(app)/paired" as any);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [couple, partner, user, params.incomingCode]);
|
||||||
|
|
||||||
// One-time prefill: apply a code carried by a join link (route param), then
|
// 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
|
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||||
@@ -68,15 +87,15 @@ export default function PairingScreen() {
|
|||||||
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||||
if (isValidInviteCode(routeCode)) {
|
if (isValidInviteCode(routeCode)) {
|
||||||
await applyPrefill(routeCode);
|
await applyPrefill(routeCode);
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user?.id);
|
||||||
if (!cancelled) setPrefillAttempted(true);
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stashedCode = await getPendingInviteCode();
|
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
|
||||||
if (stashedCode) {
|
if (stashedCode) {
|
||||||
await applyPrefill(stashedCode);
|
await applyPrefill(stashedCode);
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user?.id);
|
||||||
if (!cancelled) setPrefillAttempted(true);
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,7 +110,7 @@ export default function PairingScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [couple, params.code, prefillAttempted]);
|
}, [couple, params.code, prefillAttempted, user?.id]);
|
||||||
|
|
||||||
const acceptClipboardOffer = () => {
|
const acceptClipboardOffer = () => {
|
||||||
if (!clipboardOfferCode) return;
|
if (!clipboardOfferCode) return;
|
||||||
@@ -113,7 +132,7 @@ export default function PairingScreen() {
|
|||||||
|
|
||||||
// Poll every 5 seconds as fallback
|
// Poll every 5 seconds as fallback
|
||||||
const pollInterval = setInterval(() => {
|
const pollInterval = setInterval(() => {
|
||||||
fetchCouple();
|
void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -140,11 +159,12 @@ export default function PairingScreen() {
|
|||||||
// Validate invite code format (8 alphanumeric characters)
|
// Validate invite code format (8 alphanumeric characters)
|
||||||
const sanitizedCode = inviteCode.trim().toUpperCase();
|
const sanitizedCode = inviteCode.trim().toUpperCase();
|
||||||
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
|
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
|
||||||
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
|
setJoinError("That code isn't the right shape. Codes are 8 letters and numbers.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setJoinError(null);
|
||||||
try {
|
try {
|
||||||
await coupleApi.join(sanitizedCode);
|
await coupleApi.join(sanitizedCode);
|
||||||
|
|
||||||
@@ -152,14 +172,26 @@ export default function PairingScreen() {
|
|||||||
await fetchCouple(); // Fetch couple data
|
await fetchCouple(); // Fetch couple data
|
||||||
// Joining claims both members' sealed answers and computes matches
|
// Joining claims both members' sealed answers and computes matches
|
||||||
// server-side, so the joiner needs a fresh matches list too.
|
// server-side, so the joiner needs a fresh matches list too.
|
||||||
void useMatchStore.getState().fetchMatches(true);
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
Events.coupleJoined();
|
Events.coupleJoined();
|
||||||
|
|
||||||
Alert.alert("Success", "You are now paired!", [
|
router.replace("/(app)/paired" as any);
|
||||||
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
|
} catch (error: any) {
|
||||||
]);
|
const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined;
|
||||||
} catch (error: any) {
|
const code = details?.error?.code;
|
||||||
Alert.alert("Error", getPairingError(error));
|
const message = code === "couple_full"
|
||||||
|
? "That code is already in use by two people."
|
||||||
|
: code === "already_paired"
|
||||||
|
? "You're already paired."
|
||||||
|
: code === "invalid_invite_code" && error.status === 404
|
||||||
|
? "We can't find that code. It may have been cancelled. Ask them to send a new invite."
|
||||||
|
: code === "invalid_invite_code"
|
||||||
|
? "That code isn't the right shape. Codes are 8 letters and numbers."
|
||||||
|
: /network|fetch|timeout/i.test(error?.message ?? "")
|
||||||
|
? "Can't reach Sauci. Check your connection and try again."
|
||||||
|
: getPairingError(error);
|
||||||
|
setJoinError(message);
|
||||||
|
AccessibilityInfo.announceForAccessibility(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -169,13 +201,14 @@ export default function PairingScreen() {
|
|||||||
if (couple?.invite_code) {
|
if (couple?.invite_code) {
|
||||||
await Clipboard.setStringAsync(couple.invite_code);
|
await Clipboard.setStringAsync(couple.invite_code);
|
||||||
Events.inviteCodeCopied();
|
Events.inviteCodeCopied();
|
||||||
Alert.alert("Copied", "Invite code copied to clipboard");
|
Alert.alert("Copied", "Invite code copied to clipboard");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInviteCodeChange = (value: string) => {
|
const handleInviteCodeChange = (value: string) => {
|
||||||
setWasPrefilled(false);
|
setWasPrefilled(false);
|
||||||
setInviteCode(value);
|
setInviteCode(value);
|
||||||
|
setJoinError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareMessage = couple?.invite_code
|
const shareMessage = couple?.invite_code
|
||||||
@@ -204,39 +237,100 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shareViaSMS = async () => {
|
const shareViaSMS = async () => {
|
||||||
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
|
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
|
||||||
await Linking.openURL(url);
|
await Linking.openURL(url);
|
||||||
Events.codeShared();
|
Events.codeShared();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const completeJoinedInvite = async () => {
|
||||||
|
await fetchUser();
|
||||||
|
await fetchCouple();
|
||||||
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
|
router.replace("/(app)/paired" as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelInvite = async (afterCancel?: () => Promise<void>) => {
|
||||||
|
try {
|
||||||
|
await coupleApi.cancelInvite();
|
||||||
|
await fetchUser();
|
||||||
|
await fetchCouple();
|
||||||
|
if (afterCancel) await afterCancel();
|
||||||
|
Events.pairingCancelled();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const apiCode = error instanceof ApiError
|
||||||
|
? (error.details as { error?: { code?: string } } | undefined)?.error?.code
|
||||||
|
: undefined;
|
||||||
|
if (apiCode === "invite_already_joined") {
|
||||||
|
await completeJoinedInvite();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
"Couldn't cancel invite",
|
||||||
|
"Your invite may still be active. Check your connection and try again.",
|
||||||
|
[{ text: "Not now", style: "cancel" }, { text: "Try again", onPress: () => { void cancelInvite(afterCancel); } }],
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyInviteLink = async () => {
|
||||||
|
if (couple?.invite_code) {
|
||||||
|
await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelPairing = async () => {
|
const handleCancelPairing = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Cancel Pairing",
|
"Cancel this invite?",
|
||||||
"Are you sure you want to cancel? Your invite code will be deleted.",
|
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
||||||
[
|
[
|
||||||
{ text: "Keep Waiting", style: "cancel" },
|
{ text: "Keep invite", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Cancel Pairing",
|
text: "Cancel invite",
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await coupleApi.cancel();
|
await cancelInvite();
|
||||||
|
} finally {
|
||||||
await fetchUser();
|
|
||||||
await fetchCouple();
|
|
||||||
Events.pairingCancelled();
|
|
||||||
} catch (error: any) {
|
|
||||||
Alert.alert("Error", getPairingError(error));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const incomingCode = typeof params.incomingCode === "string" && isValidInviteCode(normalizeInviteCode(params.incomingCode))
|
||||||
|
? normalizeInviteCode(params.incomingCode)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleCancelOwnAndPrefill = () => {
|
||||||
|
if (!incomingCode) return;
|
||||||
|
Alert.alert(
|
||||||
|
"Cancel this invite?",
|
||||||
|
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
||||||
|
[
|
||||||
|
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) },
|
||||||
|
{
|
||||||
|
text: "Cancel mine and join", style: "destructive", onPress: async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await cancelInvite(async () => {
|
||||||
|
setInviteCode(incomingCode);
|
||||||
|
setWasPrefilled(true);
|
||||||
|
await clearPendingInviteCode(user?.id);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isAuthLoading) {
|
if (isAuthLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -265,11 +359,23 @@ export default function PairingScreen() {
|
|||||||
>
|
>
|
||||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.title}>Partner Code</Text>
|
<Text style={styles.title}>Your invite</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* Icon */}
|
{incomingCode && !isIncomingConflictDismissed && (
|
||||||
|
<GlassCard variant="elevated">
|
||||||
|
<Text style={styles.conflictTitle}>Use this code instead?</Text>
|
||||||
|
<Text style={styles.conflictBody}>You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted.</Text>
|
||||||
|
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
|
||||||
|
Cancel mine and join
|
||||||
|
</GlassButton>
|
||||||
|
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
||||||
|
<Text style={styles.cancelButtonText}>Keep my invite</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
{/* Icon */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(200).duration(500)}
|
entering={FadeInDown.delay(200).duration(500)}
|
||||||
style={styles.iconSection}
|
style={styles.iconSection}
|
||||||
@@ -283,7 +389,7 @@ export default function PairingScreen() {
|
|||||||
<Ionicons name="heart" size={40} color={colors.text} />
|
<Ionicons name="heart" size={40} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Share this code with your partner to link your accounts
|
Your answers are ready to compare when you both answer
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -295,8 +401,7 @@ export default function PairingScreen() {
|
|||||||
>
|
>
|
||||||
<GlassCard variant="elevated">
|
<GlassCard variant="elevated">
|
||||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||||
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
|
You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer.
|
||||||
They will unlock the moment your partner joins.
|
|
||||||
</Text>
|
</Text>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -313,12 +418,12 @@ export default function PairingScreen() {
|
|||||||
onPress={copyToClipboard}
|
onPress={copyToClipboard}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
|
<Text style={styles.code} accessibilityLabel={`Invite code ${couple.invite_code.toUpperCase().split("").join(" ")}`}>{couple.invite_code.toUpperCase()}</Text>
|
||||||
<View style={styles.copyIcon}>
|
<View style={styles.copyIcon}>
|
||||||
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
|
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.tapToCopy}>Tap to copy</Text>
|
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -341,7 +446,7 @@ export default function PairingScreen() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
||||||
>
|
>
|
||||||
Share Invite Code
|
Share invite
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -354,10 +459,14 @@ export default function PairingScreen() {
|
|||||||
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
|
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
|
||||||
<Text style={styles.quickShareLabel}>Messages</Text>
|
<Text style={styles.quickShareLabel}>Messages</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
|
||||||
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
||||||
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.quickShareButton} onPress={copyInviteLink} activeOpacity={0.7} testID="pairing-copy-link" accessibilityLabel="Copy invite link">
|
||||||
|
<Ionicons name="link-outline" size={20} color={colors.text} />
|
||||||
|
<Text style={styles.quickShareLabel}>Copy link</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Waiting indicator */}
|
{/* Waiting indicator */}
|
||||||
@@ -367,8 +476,9 @@ export default function PairingScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.waitingBadge}>
|
<View style={styles.waitingBadge}>
|
||||||
<ActivityIndicator size="small" color={colors.primary} />
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
|
<Text style={styles.waitingText}>Waiting for them to join</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Cancel Button */}
|
{/* Cancel Button */}
|
||||||
@@ -382,7 +492,7 @@ export default function PairingScreen() {
|
|||||||
style={styles.cancelButton}
|
style={styles.cancelButton}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
|
<Text style={styles.cancelButtonText}>Cancel invite</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -427,7 +537,7 @@ export default function PairingScreen() {
|
|||||||
<Ionicons name="link" size={40} color={colors.text} />
|
<Ionicons name="link" size={40} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Link with your partner to start matching
|
{PAIRING_PRIVACY_SUMMARY}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -481,16 +591,21 @@ export default function PairingScreen() {
|
|||||||
/>
|
/>
|
||||||
{wasPrefilled && (
|
{wasPrefilled && (
|
||||||
<Text style={styles.prefilledCaption}>
|
<Text style={styles.prefilledCaption}>
|
||||||
Code applied from your invite link
|
Code from your invite link
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{joinError && (
|
||||||
|
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
|
||||||
|
{joinError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleJoinCouple}
|
onPress={handleJoinCouple}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Join Partner
|
Join your partner
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -516,7 +631,7 @@ export default function PairingScreen() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Create New Code
|
Create invite code
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -668,7 +783,7 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
maxWidth: 280,
|
maxWidth: 280,
|
||||||
},
|
},
|
||||||
sealedCountText: {
|
sealedCountText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -719,10 +834,32 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.xl,
|
marginTop: spacing.xl,
|
||||||
},
|
},
|
||||||
|
|
||||||
waitingText: {
|
waitingText: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
|
conflictTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
conflictBody: {
|
||||||
|
...typography.callout,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.warning,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
joinError: {
|
||||||
|
...typography.callout,
|
||||||
|
color: colors.error,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
cancelSection: {
|
cancelSection: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: spacing.xl,
|
marginTop: spacing.xl,
|
||||||
|
|||||||
+18
-10
@@ -37,7 +37,8 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Stack, router, usePathname } from "expo-router";
|
import { Stack, router, usePathname } from "expo-router";
|
||||||
|
import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
// GestureHandlerRootView is only needed on native platforms
|
// GestureHandlerRootView is only needed on native platforms
|
||||||
@@ -129,14 +130,20 @@ export default function RootLayout() {
|
|||||||
// are handled separately from the Supabase auth deep link flow below.
|
// are handled separately from the Supabase auth deep link flow below.
|
||||||
const inviteLink = parseInviteLinkCode(url);
|
const inviteLink = parseInviteLinkCode(url);
|
||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
Events.inviteLinkOpened(inviteLink.source);
|
Events.inviteLinkOpened(inviteLink.source);
|
||||||
await stashPendingInviteCode(inviteLink.code);
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
|
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||||
const { isAuthenticated, user } = useAuthStore.getState();
|
if (user?.couple_id) {
|
||||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
|
||||||
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
if (user.couple_id) {
|
||||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
|
||||||
|
if (isAuthenticated && !onboardingPending) {
|
||||||
|
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +271,8 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<ReducedMotionConfig mode={ReduceMotion.System} />
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
|||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
|
||||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
|
||||||
|
|
||||||
// Check once, after auth resolves, whether an invite code is waiting to be
|
// 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).
|
// applied (e.g. the user tapped a join link before signing in).
|
||||||
@@ -18,16 +17,17 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getPendingInviteCode().then((code) => {
|
if (!user?.id) return;
|
||||||
|
setPendingInviteCheck(null);
|
||||||
|
getPendingInviteCode(user.id).then((code) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHasPendingInvite(!!code);
|
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
|
||||||
setPendingCodeChecked(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
|
||||||
|
|
||||||
// Show loading state while auth is being determined
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -56,7 +56,7 @@ export default function Index() {
|
|||||||
// Wait for the one-time pending invite code check before deciding the
|
// 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
|
// route, so a stashed code from a join link is applied without an extra
|
||||||
// trip through the home screen.
|
// trip through the home screen.
|
||||||
if (!pendingCodeChecked) {
|
if (pendingInviteCheck?.userId !== user?.id) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
@@ -64,7 +64,7 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingInvite) {
|
if (pendingInviteCheck?.hasInvite) {
|
||||||
return <Redirect href="/(app)/pairing" />;
|
return <Redirect href="/(app)/pairing" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
|
|||||||
await coupleApi.getState();
|
await coupleApi.getState();
|
||||||
await coupleApi.create();
|
await coupleApi.create();
|
||||||
await coupleApi.join("ABCD2345");
|
await coupleApi.join("ABCD2345");
|
||||||
|
await coupleApi.cancelInvite();
|
||||||
await coupleApi.cancel();
|
await coupleApi.cancel();
|
||||||
|
|
||||||
expect(request.mock.calls).toEqual([
|
expect(request.mock.calls).toEqual([
|
||||||
["/v1/couple"],
|
["/v1/couple"],
|
||||||
["/v1/couple", { method: "POST", body: {} }],
|
["/v1/couple", { method: "POST", body: {} }],
|
||||||
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
||||||
|
["/v1/couple/invite", { method: "DELETE" }],
|
||||||
["/v1/couple", { method: "DELETE" }],
|
["/v1/couple", { method: "DELETE" }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
||||||
|
|
||||||
describe("buildInviteShareMessage", () => {
|
describe("buildInviteShareMessage", () => {
|
||||||
it("leads with a plain invite when there are no sealed answers", () => {
|
it("keeps a recoverable bare code in a separate sentence", () => {
|
||||||
const message = buildInviteShareMessage("ABCD1234", 0);
|
const message = buildInviteShareMessage("AB12CD34", 3);
|
||||||
expect(message).toBe(
|
expect(message).toContain("https://sauci.app/join/AB12CD34");
|
||||||
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
expect(message).toContain("enter code AB12CD34 in Sauci");
|
||||||
);
|
expect(message).toContain("Vote-style questions show shared outcomes");
|
||||||
|
expect(message).toContain("open answers can be visible to us both after we each answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leads with earned value and singular wording for exactly one sealed answer", () => {
|
it("does not claim a sealed outcome before answers exist", () => {
|
||||||
const message = buildInviteShareMessage("ABCD1234", 1);
|
expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
|
||||||
expect(message).toBe(
|
|
||||||
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pluralises for more than one sealed answer", () => {
|
|
||||||
const message = buildInviteShareMessage("ABCD1234", 5);
|
|
||||||
expect(message).toContain("I have already answered 5 questions about us.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
jest.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { clearPairedUnlockSeen, hasSeenPairedUnlock, markPairedUnlockSeen } from "../../lib/pairedUnlockSeen";
|
||||||
|
|
||||||
|
describe("paired unlock state", () => {
|
||||||
|
it("is scoped to both account and couple", async () => {
|
||||||
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue("true");
|
||||||
|
await hasSeenPairedUnlock("user-a", "couple-a");
|
||||||
|
await markPairedUnlockSeen("user-a", "couple-a");
|
||||||
|
await clearPairedUnlockSeen("user-a", "couple-a");
|
||||||
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
|
||||||
|
expect(AsyncStorage.setItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a", "true");
|
||||||
|
expect(AsyncStorage.removeItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PAIRING_PRIVACY_SUMMARY } from "../../lib/pairingPrivacyCopy";
|
||||||
|
|
||||||
|
describe("PAIRING_PRIVACY_SUMMARY", () => {
|
||||||
|
it("explains what each response style reveals before a user answers", () => {
|
||||||
|
expect(PAIRING_PRIVACY_SUMMARY).toContain("Vote-style answers reveal shared outcomes");
|
||||||
|
expect(PAIRING_PRIVACY_SUMMARY).toContain("Text, audio, photo, and who-likely responses are shown after you both answer");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
|
|||||||
await AsyncStorage.clear();
|
await AsyncStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stashes, retrieves, and clears a valid code", async () => {
|
it("stashes, retrieves, and clears a valid code for its owner", async () => {
|
||||||
await stashPendingInviteCode("abcd1234");
|
await stashPendingInviteCode("abcd1234", "user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode("user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when nothing has been stashed", async () => {
|
it("returns null when nothing has been stashed", async () => {
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
||||||
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak a claimed invite to a later account", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
await expect(getPendingInviteCode("user-b")).resolves.toBeNull();
|
||||||
|
|
||||||
|
await clearPendingInviteCode("user-b");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only one concurrently arriving account claim a pre-auth invite", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
getPendingInviteCode("user-a"),
|
||||||
|
getPendingInviteCode("user-b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([first, second].filter(Boolean)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { useMessageStore } from "@/store/messageStore";
|
|||||||
import { usePacksStore } from "@/store/packsStore";
|
import { usePacksStore } from "@/store/packsStore";
|
||||||
import { useSubscriptionStore } from "@/store/subscriptionStore";
|
import { useSubscriptionStore } from "@/store/subscriptionStore";
|
||||||
import type { Profile } from "@/types";
|
import type { Profile } from "@/types";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
id: "me",
|
id: "me",
|
||||||
@@ -259,6 +260,7 @@ describe("authStore", () => {
|
|||||||
|
|
||||||
it("clears sealed answers during an explicit sign-out", async () => {
|
it("clears sealed answers during an explicit sign-out", async () => {
|
||||||
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
||||||
|
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
|
||||||
|
|
||||||
await useAuthStore.getState().signOut();
|
await useAuthStore.getState().signOut();
|
||||||
|
|
||||||
@@ -267,6 +269,7 @@ describe("authStore", () => {
|
|||||||
sealedCount: 0,
|
sealedCount: 0,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
|
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears sealed answers when the auth listener clears the user", () => {
|
it("clears sealed answers when the auth listener clears the user", () => {
|
||||||
@@ -281,6 +284,14 @@ describe("authStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a pending invite when the auth listener clears the user", async () => {
|
||||||
|
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
|
||||||
|
useAuthStore.setState({ user: profile, isAuthenticated: true } as any);
|
||||||
|
useAuthStore.getState().setUser(null);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a pending Apple name scoped to its original subject', () => {
|
it('keeps a pending Apple name scoped to its original subject', () => {
|
||||||
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||||
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
|
|||||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
const inFlight = useMatchStore.getState().fetchMatches(true);
|
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||||
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
release({ matches: [], totalCount: 0 });
|
release({ matches: [], totalCount: 0 });
|
||||||
await inFlight;
|
await inFlight;
|
||||||
|
await overlapping;
|
||||||
|
|
||||||
(apiClient.get as jest.Mock).mockClear();
|
(apiClient.get as jest.Mock).mockClear();
|
||||||
useMatchStore.setState({ hasMore: false });
|
useMatchStore.setState({ hasMore: false });
|
||||||
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
|
|||||||
expect(apiClient.get).not.toHaveBeenCalled();
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes overlapping callers await the same failed refresh', async () => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
(apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; }));
|
||||||
|
|
||||||
|
const first = useMatchStore.getState().fetchMatches(true);
|
||||||
|
const second = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
reject(new Error('network'));
|
||||||
|
await expect(first).resolves.toBe(false);
|
||||||
|
await expect(second).resolves.toBe(false);
|
||||||
|
expect(useMatchStore.getState().error).toBe('Failed to load matches');
|
||||||
|
});
|
||||||
|
|
||||||
it('appends and deduplicates later pages', async () => {
|
it('appends and deduplicates later pages', async () => {
|
||||||
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
||||||
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
|
|||||||
onPress={onInvitePress}
|
onPress={onInvitePress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
testID="swipe-sealed-answers-banner"
|
testID="swipe-sealed-answers-banner"
|
||||||
|
accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
|
||||||
>
|
>
|
||||||
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
||||||
<Text style={styles.text}>
|
<View style={styles.copy}>
|
||||||
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
|
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
|
||||||
</Text>
|
<Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.cta}>Invite</Text>
|
<Text style={styles.cta}>Invite</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginHorizontal: spacing.md,
|
marginHorizontal: spacing.md,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.md,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.lg,
|
||||||
backgroundColor: colors.backgroundLight,
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
},
|
},
|
||||||
|
copy: { flex: 1, gap: 2 },
|
||||||
|
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
|
||||||
text: {
|
text: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
|||||||
@@ -281,6 +281,18 @@ export const Events = {
|
|||||||
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||||
logEvent("invite_link_opened", { source }),
|
logEvent("invite_link_opened", { source }),
|
||||||
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
||||||
|
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
|
||||||
|
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
|
||||||
|
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
|
||||||
|
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
|
||||||
|
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
|
||||||
|
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
|
||||||
|
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
|
||||||
|
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
|
||||||
|
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
|
||||||
|
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
|
||||||
|
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
|
||||||
|
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
|
||||||
|
|
||||||
// Quiz events
|
// Quiz events
|
||||||
quizStarted: () => logEvent("quiz_started"),
|
quizStarted: () => logEvent("quiz_started"),
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const coupleApi = {
|
|||||||
body: { invite_code: inviteCode },
|
body: { invite_code: inviteCode },
|
||||||
}),
|
}),
|
||||||
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
||||||
|
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
* the recipient a concrete reason to join right away.
|
* the recipient a concrete reason to join right away.
|
||||||
*/
|
*/
|
||||||
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
|
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
|
||||||
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
|
const link = `https://sauci.app/join/${inviteCode}`;
|
||||||
if (sealedCount > 0) {
|
if (sealedCount > 0) {
|
||||||
const noun = sealedCount === 1 ? "question" : "questions";
|
const noun = sealedCount === 1 ? "question" : "questions";
|
||||||
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
|
return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
return `Join me on Sauci! Tap this link to pair up instantly: ${link}`;
|
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
const keyFor = (userId: string, coupleId: string) => `paired_unlock_seen_${userId}_${coupleId}`;
|
||||||
|
|
||||||
|
export async function hasSeenPairedUnlock(userId: string, coupleId: string): Promise<boolean> {
|
||||||
|
return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(keyFor(userId, coupleId), "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise<void> {
|
||||||
|
if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const PAIRING_PRIVACY_SUMMARY = "Vote-style answers reveal shared outcomes. Text, audio, photo, and who-likely responses are shown after you both answer.";
|
||||||
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||||
|
|
||||||
const STORAGE_KEY = "pending_invite_code";
|
const STORAGE_KEY = "pending_invite_code";
|
||||||
|
let storageOperation = Promise.resolve();
|
||||||
|
|
||||||
|
function withStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const next = storageOperation.then(operation, operation);
|
||||||
|
storageOperation = next.then(() => undefined, () => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingInviteCode {
|
||||||
|
code: string;
|
||||||
|
/** The account that received or first claimed this deferred invite. */
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingInviteCode(value: string | null): PendingInviteCode | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// Treat legacy, unscoped values as a pre-auth hand-off. The first account
|
||||||
|
// that intentionally enters the pairing flow claims it below.
|
||||||
|
if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null };
|
||||||
|
|
||||||
export async function stashPendingInviteCode(code: string): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
|
||||||
|
if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null;
|
||||||
|
if (parsed.userId !== null && typeof parsed.userId !== "string") return null;
|
||||||
|
return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error stashing pending invite code:", error);
|
console.error("Error stashing pending invite code:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingInviteCode(): Promise<string | null> {
|
export async function getPendingInviteCode(userId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
return await withStorageLock(async () => {
|
||||||
if (value && isValidInviteCode(value)) {
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
return normalizeInviteCode(value);
|
if (!pending || (pending.userId && pending.userId !== userId)) return null;
|
||||||
}
|
|
||||||
return null;
|
// A hand-off made before sign-in becomes owned by the account that
|
||||||
|
// reaches pairing. Later account switches cannot consume it.
|
||||||
|
if (!pending.userId) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...pending, userId }));
|
||||||
|
}
|
||||||
|
return pending.code;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading pending invite code:", error);
|
console.error("Error reading pending invite code:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingInviteCode(): Promise<void> {
|
export async function clearPendingInviteCode(userId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
await withStorageLock(async () => {
|
||||||
|
if (userId) {
|
||||||
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
|
if (pending?.userId && pending.userId !== userId) return;
|
||||||
|
}
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing pending invite code:", error);
|
console.error("Error clearing pending invite code:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { coupleApi } from "../lib/coupleApi";
|
|||||||
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
||||||
import { syncTimezone } from "../lib/reportedTimezone";
|
import { syncTimezone } from "../lib/reportedTimezone";
|
||||||
import { Events } from "../lib/analytics";
|
import { Events } from "../lib/analytics";
|
||||||
|
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
|
||||||
|
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
|
||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
|
|
||||||
export interface AuthSessionSnapshot {
|
export interface AuthSessionSnapshot {
|
||||||
@@ -201,6 +203,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
Events.signOut();
|
Events.signOut();
|
||||||
|
|
||||||
|
// The paired-unlock presentation is scoped to this account and current
|
||||||
|
// relationship. Remove it before local identity state is cleared.
|
||||||
|
const unlockUserId = get().user?.id;
|
||||||
|
const unlockCoupleId = get().couple?.id ?? get().user?.couple_id ?? null;
|
||||||
|
if (unlockUserId) {
|
||||||
|
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
|
||||||
|
}
|
||||||
|
await clearPendingInviteCode(unlockUserId);
|
||||||
|
|
||||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -236,6 +247,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setUser: (user) => {
|
setUser: (user) => {
|
||||||
|
const priorUserId = user === null ? get().user?.id : undefined;
|
||||||
|
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
@@ -246,6 +259,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
|
void clearPendingInviteCode(priorUserId);
|
||||||
|
if (priorUserId) {
|
||||||
|
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
||||||
|
}
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean }
|
|||||||
// account's request for the same key, and a stale generation's `finally` must not
|
// account's request for the same key, and a stale generation's `finally` must not
|
||||||
// clear an entry a newer generation just claimed.
|
// clear an entry a newer generation just claimed.
|
||||||
const inFlight = new Map<string, number>();
|
const inFlight = new Map<string, number>();
|
||||||
|
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
|
||||||
|
|
||||||
// Rate limit: 12 hours in milliseconds
|
// Rate limit: 12 hours in milliseconds
|
||||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||||
@@ -80,7 +81,7 @@ interface MatchState {
|
|||||||
*/
|
*/
|
||||||
generation: number;
|
generation: number;
|
||||||
// Methods
|
// Methods
|
||||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
isNudging: false,
|
isNudging: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
|
||||||
fetchMatches: async (refresh = false, options = {}) => {
|
fetchMatches: (refresh = false, options = {}) => {
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
const myGeneration = get().generation;
|
const myGeneration = get().generation;
|
||||||
|
|
||||||
// Early return if no couple - user isn't paired yet
|
// Early return if no couple - user isn't paired yet
|
||||||
if (!coupleId) {
|
if (!coupleId) {
|
||||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||||
return;
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
const activeRequest = activeInFlight.get('active');
|
||||||
|
if (activeRequest?.generation === myGeneration) return activeRequest.promise;
|
||||||
|
if (state.isLoadingMore && !refresh) return Promise.resolve(false);
|
||||||
|
|
||||||
// Nothing on screen yet is the only case that warrants blanking the list
|
// Nothing on screen yet is the only case that warrants blanking the list
|
||||||
// for a spinner; every other refresh leaves the cached rows in place.
|
// for a spinner; every other refresh leaves the cached rows in place.
|
||||||
@@ -149,17 +152,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
if (refresh) {
|
if (refresh) {
|
||||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||||
} else {
|
} else {
|
||||||
if (!state.hasMore) return;
|
if (!state.hasMore) return Promise.resolve(false);
|
||||||
set({ isLoadingMore: true });
|
set({ isLoadingMore: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlight.set('active', myGeneration);
|
const request = (async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const currentPage = refresh ? 0 : state.page;
|
const currentPage = refresh ? 0 : state.page;
|
||||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||||
// A sign-out/account switch that happened while this request was in
|
// A sign-out/account switch that happened while this request was in
|
||||||
// flight must not let its response populate the next account's store.
|
// flight must not let its response populate the next account's store.
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
const nonArchivedMatches = result.matches;
|
const nonArchivedMatches = result.matches;
|
||||||
const totalCount = result.totalCount;
|
const totalCount = result.totalCount;
|
||||||
const archivedMatchIds = state.archivedMatchIds;
|
const archivedMatchIds = state.archivedMatchIds;
|
||||||
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
set({ isLoadingMore: false, hasMore: false });
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedData = nonArchivedMatches;
|
const sortedData = nonArchivedMatches;
|
||||||
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
loadedAt,
|
loadedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// An old generation's cleanup must not delete a newer generation's guard.
|
// An old generation's cleanup must not delete a newer generation's guard.
|
||||||
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
|
if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active');
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
activeInFlight.set('active', { generation: myGeneration, promise: request });
|
||||||
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsSeen: async (matchId) => {
|
markAsSeen: async (matchId) => {
|
||||||
@@ -296,6 +304,7 @@ clearMatches: () => {
|
|||||||
// A request belonging to the signed-out user must not suppress the next
|
// A request belonging to the signed-out user must not suppress the next
|
||||||
// one: the guard is a duplicate-request check, not a lock on the data.
|
// one: the guard is a duplicate-request check, not a lock on the data.
|
||||||
inFlight.clear();
|
inFlight.clear();
|
||||||
|
activeInFlight.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Archive methods
|
// Archive methods
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { redactInvitePaths } from './providers'
|
||||||
|
|
||||||
|
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
|
||||||
|
const providerPath = new URL('./providers.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('PostHog invite privacy boundary', () => {
|
||||||
|
it('does not send a global page view for invite routes', async () => {
|
||||||
|
const source = await readFile(pageViewPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("pathname?.startsWith('/join/')")
|
||||||
|
expect(source.indexOf("pathname?.startsWith('/join/')")).toBeLessThan(source.indexOf("posthog.capture('$pageview'"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables automatic page-leave capture and redacts join URLs', async () => {
|
||||||
|
const source = await readFile(providerPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('capture_pageview: false')
|
||||||
|
expect(source).toContain('capture_pageleave: false')
|
||||||
|
expect(source).toContain('get_current_url: redactInviteUrl')
|
||||||
|
expect(source).toContain('before_send: redactInvitePathsBeforeSend')
|
||||||
|
expect(source).toContain("url.pathname.startsWith('/join/')")
|
||||||
|
expect(source).toContain('/join/[invite]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an invite code from every serialized URL or path property', () => {
|
||||||
|
const inviteCode = 'A4K9BT2Q'
|
||||||
|
const event = {
|
||||||
|
event: 'join_page_viewed',
|
||||||
|
properties: {
|
||||||
|
$current_url: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
$pathname: `/join/${inviteCode}`,
|
||||||
|
$referrer: `https://sauci.app/join/${inviteCode}?source=message`,
|
||||||
|
$session_entry_url: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
nested: { previous_path: `/join/${inviteCode}` },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(redactInvitePaths(event))
|
||||||
|
|
||||||
|
expect(serialized).not.toContain(inviteCode)
|
||||||
|
expect(serialized).toContain('/join/[invite]')
|
||||||
|
expect(serialized).toContain('join_page_viewed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,10 +7,14 @@ import { usePostHog } from 'posthog-js/react'
|
|||||||
function PostHogPageViewInner() {
|
function PostHogPageViewInner() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname && posthog) {
|
// Invite codes are bearer-like tokens. The join route emits its own
|
||||||
|
// aggregate-only event, so it must never produce a URL-bearing page view.
|
||||||
|
if (pathname?.startsWith('/join/')) return
|
||||||
|
|
||||||
|
if (pathname && posthog) {
|
||||||
let url = window.origin + pathname
|
let url = window.origin + pathname
|
||||||
if (searchParams.toString()) {
|
if (searchParams.toString()) {
|
||||||
url = url + '?' + searchParams.toString()
|
url = url + '?' + searchParams.toString()
|
||||||
|
|||||||
@@ -62,13 +62,20 @@ body {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-light {
|
.glass-light {
|
||||||
background: rgba(22, 33, 62, 0.6);
|
background: rgba(22, 33, 62, 0.6);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Solid pairing surface. Join links must not rely on glass or blur. */
|
||||||
|
.surface-solid {
|
||||||
|
background: #17171c;
|
||||||
|
border: 1px solid #303036;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Gradient text */
|
/* Gradient text */
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => {
|
|||||||
expect(source).not.toMatch(/fetch\(/)
|
expect(source).not.toMatch(/fetch\(/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('attempts the app scheme link and shows the raw code fallback', async () => {
|
it('only attempts the app scheme link after an explicit Open in Sauci action', async () => {
|
||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain('buildInviteSchemeUrl')
|
expect(source).toContain('buildInviteSchemeUrl')
|
||||||
|
expect(source).toContain('Open in Sauci')
|
||||||
|
expect(source.indexOf('const handleOpenInApp')).toBeLessThan(source.indexOf('window.location.href = buildInviteSchemeUrl'))
|
||||||
expect(source).toContain('invite-code-display')
|
expect(source).toContain('invite-code-display')
|
||||||
expect(source).toContain('Copy code')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies only the raw code, not a share message', async () => {
|
it('copies only the raw code, not a share message', async () => {
|
||||||
@@ -37,13 +38,53 @@ describe('/join/[code] JoinClient', () => {
|
|||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain("posthog?.capture('join_page_viewed'")
|
expect(source).toContain("posthog?.capture('join_page_viewed'")
|
||||||
expect(source).toContain("posthog?.capture('join_page_code_copied')")
|
expect(source).toContain("posthog?.capture('join_page_code_copied'")
|
||||||
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
|
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_open_app_clicked'")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_open_app_fallback_shown'")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_offline_shown'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not put an invite code in a PostHog payload', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).not.toMatch(/posthog\?\.capture\([^)]*normalizedCode/)
|
||||||
|
expect(source).not.toMatch(/posthog\?\.capture\([^)]*\bcode\s*:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the required recovery and accessibility states', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('Copy this before you install')
|
||||||
|
expect(source).toContain('setTimeout(() =>')
|
||||||
|
expect(source).toContain('}, 1500)')
|
||||||
|
expect(source).toContain("document.visibilityState === 'visible'")
|
||||||
|
expect(source).toContain("aria-live=\"polite\"")
|
||||||
|
expect(source).toContain('navigator.onLine')
|
||||||
|
expect(source).toContain('surface-solid')
|
||||||
|
expect(source).not.toContain('glass-light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes comparison and open-answer visibility truthfully', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('Vote-style questions reveal shared outcomes')
|
||||||
|
expect(source).toContain('open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.')
|
||||||
|
expect(source).not.toContain('Neither of you sees the other’s individual answers.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses PostHog-recognized selectors to exclude the invite-code DOM from capture and replay', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('ph-no-capture ph-mask')
|
||||||
|
expect(source).not.toContain('data-ph-no-capture')
|
||||||
|
expect(source).not.toContain('data-ph-mask')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a clear fallback message for a malformed invite code', async () => {
|
it('shows a clear fallback message for a malformed invite code', async () => {
|
||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain('Invite link not recognized')
|
expect(source).toContain('Invite link not recognized')
|
||||||
|
expect(source).not.toContain('has expired')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -7,35 +7,66 @@ import { usePostHog } from 'posthog-js/react'
|
|||||||
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
||||||
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
|
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
|
||||||
|
|
||||||
|
type Platform = 'ios' | 'android' | 'desktop' | 'other'
|
||||||
|
|
||||||
|
function getPlatform(): Platform {
|
||||||
|
if (typeof navigator === 'undefined') return 'other'
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent
|
||||||
|
|
||||||
|
if (/iPad|iPhone|iPod/i.test(userAgent)) return 'ios'
|
||||||
|
if (/Android/i.test(userAgent)) return 'android'
|
||||||
|
if (/Macintosh|Windows|Linux/i.test(userAgent)) return 'desktop'
|
||||||
|
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
export function JoinClient({ code }: { code: string }) {
|
export function JoinClient({ code }: { code: string }) {
|
||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const hasAttemptedOpen = useRef(false)
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [openAppFallbackVisible, setOpenAppFallbackVisible] = useState(false)
|
||||||
|
const [platform, setPlatform] = useState<Platform>('other')
|
||||||
|
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const hasCopiedCode = useRef(false)
|
||||||
|
|
||||||
const isValid = isValidInviteCode(code)
|
const isValid = isValidInviteCode(code)
|
||||||
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, 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(() => {
|
useEffect(() => {
|
||||||
posthog?.capture('join_page_viewed', { code_valid: isValid })
|
const detectedPlatform = getPlatform()
|
||||||
|
setPlatform(detectedPlatform)
|
||||||
|
posthog?.capture('join_page_viewed', { code_valid: isValid, platform: detectedPlatform })
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!isValid || hasAttemptedOpen.current) return
|
const updateOnlineStatus = () => setIsOnline(navigator.onLine)
|
||||||
hasAttemptedOpen.current = true
|
|
||||||
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
|
||||||
}, [isValid, normalizedCode])
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
updateOnlineStatus()
|
||||||
|
window.addEventListener('online', updateOnlineStatus)
|
||||||
|
window.addEventListener('offline', updateOnlineStatus)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', updateOnlineStatus)
|
||||||
|
window.removeEventListener('offline', updateOnlineStatus)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnline) posthog?.capture('join_page_offline_shown')
|
||||||
|
}, [isOnline, posthog])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCopy = async (trigger: 'code_block' | 'fallback_note') => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(normalizedCode)
|
await navigator.clipboard.writeText(normalizedCode)
|
||||||
|
hasCopiedCode.current = true
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
posthog?.capture('join_page_code_copied')
|
posthog?.capture('join_page_code_copied', { trigger })
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy invite code:', err)
|
console.error('Failed to copy invite code:', err)
|
||||||
@@ -43,60 +74,95 @@ export function JoinClient({ code }: { code: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStoreClick = (store: 'app_store' | 'play_store') => {
|
const handleStoreClick = (store: 'app_store' | 'play_store') => {
|
||||||
posthog?.capture('join_page_store_button_clicked', { store })
|
if (!isOnline) return
|
||||||
|
posthog?.capture('join_page_store_button_clicked', { store, code_copied_first: hasCopiedCode.current })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenInApp = () => {
|
||||||
|
setOpenAppFallbackVisible(false)
|
||||||
|
posthog?.capture('join_page_open_app_clicked', { platform })
|
||||||
|
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
||||||
|
|
||||||
|
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
|
||||||
|
fallbackTimer.current = setTimeout(() => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
setOpenAppFallbackVisible(true)
|
||||||
|
posthog?.capture('join_page_open_app_fallback_shown', { platform })
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
<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">
|
<div className="surface-solid 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>
|
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
|
||||||
<p className="text-white/60 mb-8">
|
<p className="text-white/70 mb-8">
|
||||||
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
|
This invite link looks incomplete. Ask your partner to send a new one from the Sauci app.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/" className="btn-primary inline-block">
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
Back to Home
|
<Link href="/" className="btn-primary inline-block">
|
||||||
</Link>
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={malformedStoreUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary inline-block"
|
||||||
|
>
|
||||||
|
Get Sauci
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyAnnouncement = copied ? 'Invite code copied.' : ''
|
||||||
|
const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.'
|
||||||
|
const desktopCopy = platform === 'desktop'
|
||||||
|
? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.'
|
||||||
|
: 'Join to compare answers together. Vote-style questions reveal shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background ph-no-capture ph-mask">
|
||||||
{/* Header */}
|
|
||||||
<header className="py-6 px-6 border-b border-white/5">
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
<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">
|
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
<span>Back to Home</span>
|
<span>Back to Home</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
<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">
|
{!isOnline && (
|
||||||
<Heart className="w-10 h-10 text-primary" />
|
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status">
|
||||||
|
You're offline. Your code is below, and it still works.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center mx-auto mb-8 shadow-[0_8px_28px_rgba(233,69,96,0.28)]">
|
||||||
|
<Heart className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to Sauci</h1>
|
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
||||||
<p className="text-white/60 mb-10">
|
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
||||||
Open the app to pair up instantly. If it doesn't open automatically, copy your code below and
|
|
||||||
enter it after you install Sauci.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="glass-light p-6 sm:p-8 mb-8">
|
<section className="surface-solid p-6 sm:p-8 mb-6 ph-no-capture ph-mask" aria-labelledby="invite-code-heading">
|
||||||
<p className="text-white/60 text-sm mb-3">Your invite code</p>
|
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
||||||
<p
|
<p
|
||||||
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||||
data-testid="invite-code-display"
|
data-testid="invite-code-display"
|
||||||
|
aria-label={normalizedCode.split('').join(' ')}
|
||||||
>
|
>
|
||||||
{normalizedCode}
|
{normalizedCode}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-white/70 text-sm mb-5">Copy this before you install. You'll enter it in the app.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={() => void handleCopy('code_block')}
|
||||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
@@ -111,33 +177,59 @@ export function JoinClient({ code }: { code: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span className="sr-only" role="status" aria-live="polite">{copyAnnouncement}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="text-white/40 text-sm mb-6">Don't have Sauci yet? Get the app first:</p>
|
<button type="button" onClick={handleOpenInApp} className="btn-primary w-full mb-2">
|
||||||
|
Open in Sauci
|
||||||
|
</button>
|
||||||
|
<p className="text-white/60 text-sm mb-6">Already installed? This opens the app with your code.</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
{openAppFallbackVisible && (
|
||||||
<a
|
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status" aria-live="polite">
|
||||||
href={APP_STORE_URL}
|
Nothing opened? You probably don't have Sauci yet.{' '}
|
||||||
target="_blank"
|
<button type="button" className="underline font-medium text-white" onClick={() => void handleCopy('fallback_note')}>
|
||||||
rel="noopener noreferrer"
|
Copy your code
|
||||||
onClick={() => handleStoreClick('app_store')}
|
</button>
|
||||||
className="btn-primary w-full sm:w-auto"
|
, then install below.
|
||||||
>
|
</p>
|
||||||
Download on the App Store
|
)}
|
||||||
</a>
|
|
||||||
<a
|
<section aria-labelledby="install-heading">
|
||||||
href={PLAY_STORE_URL}
|
<h2 id="install-heading" className="text-white/70 text-sm font-medium mb-4">Get Sauci</h2>
|
||||||
target="_blank"
|
<div className="flex flex-col gap-3">
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
onClick={() => handleStoreClick('play_store')}
|
href={APP_STORE_URL}
|
||||||
className="btn-secondary w-full sm:w-auto"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
Get it on Google Play
|
onClick={(event) => {
|
||||||
</a>
|
if (!isOnline) event.preventDefault()
|
||||||
</div>
|
handleStoreClick('app_store')
|
||||||
|
}}
|
||||||
|
aria-disabled={!isOnline}
|
||||||
|
title={storeUnavailableTitle}
|
||||||
|
className={`${platform === 'ios' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
Get it on the App Store
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={PLAY_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!isOnline) event.preventDefault()
|
||||||
|
handleStoreClick('play_store')
|
||||||
|
}}
|
||||||
|
aria-disabled={!isOnline}
|
||||||
|
title={storeUnavailableTitle}
|
||||||
|
className={`${platform === 'android' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
Get it on Google Play
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
<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">
|
<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>
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
|
|||||||
import { JoinClient } from './JoinClient'
|
import { JoinClient } from './JoinClient'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Join your partner on Sauci',
|
title: 'Compare answers together on Sauci',
|
||||||
description: 'Open Sauci and pair up instantly with your invite code.',
|
description: 'Copy your invite code, then join Sauci to make shared discoveries together.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function JoinPage({
|
export default async function JoinPage({
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const providerPath = new URL('./providers.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('PostHogProvider invite privacy', () => {
|
||||||
|
it('disables DOM autocapture and session recording on invite routes', async () => {
|
||||||
|
const source = await readFile(providerPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("pathname?.startsWith('/join/')")
|
||||||
|
expect(source).toContain("window.location.pathname.startsWith('/join/')")
|
||||||
|
expect(source).toContain('autocapture: !isInvitePage')
|
||||||
|
expect(source).toContain('disable_session_recording: isInvitePage')
|
||||||
|
expect(source).toContain('mask_all_text: isInvitePage')
|
||||||
|
})
|
||||||
|
})
|
||||||
+73
-14
@@ -1,20 +1,79 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
||||||
import { useEffect } from 'react'
|
import type { CaptureResult } from 'posthog-js'
|
||||||
|
import { useEffect } from 'react'
|
||||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
import { usePathname } from 'next/navigation'
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
|
||||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
|
||||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
function redactInviteUrl(defaultUrl: string): string {
|
||||||
person_profiles: 'identified_only',
|
const url = new URL(defaultUrl)
|
||||||
capture_pageview: false, // We'll capture pageviews manually for better control
|
|
||||||
capture_pageleave: true,
|
if (url.pathname.startsWith('/join/')) {
|
||||||
})
|
return `${url.origin}/join/[invite]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactInvitePaths<T>(value: T): T {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(INVITE_PATH_PATTERN, '/join/[invite]') as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(redactInvitePaths) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactInvitePaths(entry)])) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactInvitePathsBeforeSend(event: CaptureResult | null): CaptureResult | null {
|
||||||
|
return event ? redactInvitePaths(event) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||||
|
const isInvitePage = window.location.pathname.startsWith('/join/')
|
||||||
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||||
|
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
capture_pageview: false,
|
||||||
|
capture_pageleave: false,
|
||||||
|
get_current_url: redactInviteUrl,
|
||||||
|
before_send: redactInvitePathsBeforeSend,
|
||||||
|
// This must be part of initialization, not only the navigation effect
|
||||||
|
// below, so an initially loaded invite page is protected before its DOM
|
||||||
|
// can be considered for capture or recording.
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
|
||||||
|
|
||||||
|
// Invite codes are credentials. Do not let their page enter PostHog's DOM
|
||||||
|
// autocapture or session-recording pipeline, even though event properties
|
||||||
|
// and URLs are separately redacted below.
|
||||||
|
const isInvitePage = pathname?.startsWith('/join/')
|
||||||
|
posthog.set_config({
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
|
})
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
|
|||||||
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
|
|||||||
- All messages
|
- All messages
|
||||||
- All couple_packs settings
|
- All couple_packs settings
|
||||||
|
|
||||||
|
### Standalone API invite cancellation (DELETE)
|
||||||
|
|
||||||
|
`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated
|
||||||
|
creator only. It is intentionally separate from `DELETE /v1/couple`, which
|
||||||
|
continues to end an existing relationship for legacy clients.
|
||||||
|
|
||||||
|
The server locks the authenticated profile and then the couple row in one
|
||||||
|
transaction. It deletes the invite only when that caller is the sole member.
|
||||||
|
If another member has joined, it returns `409 invite_already_joined` and makes
|
||||||
|
no changes. This serializes safely with a concurrent `POST /v1/couple` join:
|
||||||
|
one operation completes first, while the other observes the resulting state.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Response on a cancelled waiting invite
|
||||||
|
{ success: true, couple_id: null }
|
||||||
|
```
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### Couple Record
|
### Couple Record
|
||||||
@@ -178,6 +195,18 @@ This prevents race conditions where multiple users try to join simultaneously.
|
|||||||
|
|
||||||
**Waiting State:**
|
**Waiting State:**
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The invite journey leads with the shared outcome, then shows the recoverable
|
||||||
|
eight-character code. It offers both a bare-code copy and a full
|
||||||
|
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
|
||||||
|
removes only an unjoined invite. Sealed answers made before invite creation
|
||||||
|
remain, while answers made while waiting are deleted with the invite. The
|
||||||
|
five-second poll surfaces an inline offline state but continues retrying.
|
||||||
|
|
||||||
|
When pairing succeeds, both members are routed once per account and couple to
|
||||||
|
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
|
||||||
|
reveals unmatched or individual answers. Existing swipe remains the named
|
||||||
|
post-pair question entry until Quick Spark is implemented.
|
||||||
┌─────────────────────────────┐
|
┌─────────────────────────────┐
|
||||||
│ ❤️ Partner Code │
|
│ ❤️ Partner Code │
|
||||||
│ │
|
│ │
|
||||||
@@ -360,6 +389,11 @@ The code is never applied automatically; the user must accept the offer.
|
|||||||
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
||||||
(web, PostHog).
|
(web, PostHog).
|
||||||
|
|
||||||
|
The mobile invite funnel also records aggregate-only prompt, share, join,
|
||||||
|
failure, completion, unlock, cancellation, clipboard, and conflict events.
|
||||||
|
Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and
|
||||||
|
answers are never sent as analytics properties.
|
||||||
|
|
||||||
## Cascade Behavior
|
## Cascade Behavior
|
||||||
|
|
||||||
When a couple is deleted, foreign key cascades clean up:
|
When a couple is deleted, foreign key cascades clean up:
|
||||||
@@ -381,4 +415,5 @@ Storage (chat-media) is manually cleaned before the cascade.
|
|||||||
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
|
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
|
||||||
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
||||||
| Couple full | Couple already has 2 members | "This couple already has two partners" |
|
| Couple full | Couple already has 2 members | "This couple already has two partners" |
|
||||||
|
| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" |
|
||||||
| DB trigger | Race condition caught | "A couple can only have 2 members" |
|
| DB trigger | Race condition caught | "A couple can only have 2 members" |
|
||||||
@@ -36,6 +36,7 @@ partially implemented states. Confirm claims against current code.
|
|||||||
|
|
||||||
- `admin-question-types-plan.md`
|
- `admin-question-types-plan.md`
|
||||||
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
||||||
|
- [Sex and fun relationship market strategy](product/sex-fun-market-strategy.md)
|
||||||
- `content-balance-implementation.md`
|
- `content-balance-implementation.md`
|
||||||
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
|
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
|
||||||
- `live-draw-feature-plan.md`
|
- `live-draw-feature-plan.md`
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Sex and fun relationship market strategy
|
||||||
|
|
||||||
|
**Research date:** 2026-09-05
|
||||||
|
**Decision:** Position Sauci as a private mutual-discovery product for adult
|
||||||
|
couples, then make a mutual match an optional real-world action.
|
||||||
|
|
||||||
|
## Scope and evidence limits
|
||||||
|
|
||||||
|
This is product-direction research, not a legal opinion, store-submission
|
||||||
|
assessment, competitor content scrape, or paid-subscription teardown. It uses
|
||||||
|
public product pages and store metadata, a public filing, official platform
|
||||||
|
policies, and a small number of public Reddit discussions. Reddit is anecdotal,
|
||||||
|
may include self-promotion, and is useful for hypotheses rather than market
|
||||||
|
sizing or claims about a gated catalogue.
|
||||||
|
|
||||||
|
Desire publicly advertises a paid "Chili" level and hotter challenges. This
|
||||||
|
research did not verify the prompts in that tier, so a claim that it contains
|
||||||
|
policy-breaking material is unsupported.
|
||||||
|
|
||||||
|
The repository's universal catalogue safety document is a plan, not proof that
|
||||||
|
all current question recommendation and pack APIs enforce `content_status`.
|
||||||
|
Current dare catalogue and send paths do enforce reviewed content. Universal
|
||||||
|
question enforcement remains future work described by the
|
||||||
|
[catalogue safety plan](catalog-content-safety-plan.md).
|
||||||
|
|
||||||
|
## Market map
|
||||||
|
|
||||||
|
| Segment and representative | Publicly visible proposition | Strategic read | Sauci opportunity |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Relationship wellness: Paired, Agape, Couply | Daily questions, guided connection, habits, and quizzes. | Can feel like maintenance or therapy. | Keep emotional safety, but make the output a shared possibility. |
|
||||||
|
| Progressive couples game: Desire | Points, dares, categories, paid access, and a "Chili" level. | The paid progression model is visible. The risk of uneven participation is a hypothesis drawn from anecdotal reports. | Test mutual interest first; retain editable, optional follow-on action. |
|
||||||
|
| Explicit single-device sex games | Position, dice, foreplay, truth-or-dare, and challenge catalogues on one device. | Direct novelty but generic couple context and a higher policy burden. | Own two-person discovery and communication, not a graphic instruction catalogue. |
|
||||||
|
| Former Official | Public reports describe private mutual selection of date and intimacy ideas. Bumble later closed it within portfolio changes. | Mutual discovery is a product hypothesis worth testing; closure does not establish that the mechanic failed. | Test private matching as the core promise, then provide a next action. |
|
||||||
|
| Sauci | Couple-isolated questions, reciprocal matches, chat, audio/photo responses, and dares exist. | The primitives exist but need a clearer journey and adult-content guardrails. | "Find something you both genuinely want to try, without either person having to risk a direct no." |
|
||||||
|
|
||||||
|
### External sources by evidence type
|
||||||
|
|
||||||
|
- **Developer marketing claims:** [Paired](https://www.paired.com/),
|
||||||
|
[Agape](https://www.getdailyagape.com/), [Couply](https://www.couply.app/),
|
||||||
|
and [Desire](https://www.desire.games/) describe their own products. They do
|
||||||
|
not establish engagement or efficacy.
|
||||||
|
- **Store metadata:** [Desire on Google Play](https://play.google.com/store/apps/details?id=com.desireapps.desire)
|
||||||
|
and [Desire on the App Store](https://apps.apple.com/us/app/desire-couples-game/id923073855)
|
||||||
|
describe published availability, rating, and paid-tier language.
|
||||||
|
- **Public filing:** [Bumble's 2024 annual report](https://s202.q4cdn.com/372973788/files/doc_financials/2024/q4/BMBL-10K-Q4-2024.pdf)
|
||||||
|
records Official's closure in the context of portfolio changes.
|
||||||
|
- **Anecdotal Reddit evidence:** [Desire and Spicer discussion](https://www.reddit.com/r/DeadBedrooms/comments/1q7gb6h/desire_couples_game_app_or_spicer_app_seeking/)
|
||||||
|
and [couples-app discussion](https://www.reddit.com/r/apps/comments/1qao3tr/any_good_couples_apps_youd_recommend/)
|
||||||
|
contain first-hand opinions, but are small and unrepresentative.
|
||||||
|
|
||||||
|
### Store metadata snapshot
|
||||||
|
|
||||||
|
The following Google Play figures were captured during this research on
|
||||||
|
2026-09-05. They are store metadata, not independently audited market share,
|
||||||
|
and can change at any time. No pricing is inferred.
|
||||||
|
|
||||||
|
| App | Google Play installs | Rating | Reviews | Use in this strategy |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| Desire | 1M+ | 4.4 | 20.9K | Progressive couples game reference. |
|
||||||
|
| Paired | 1M+ | 4.6 | 63.3K | Relationship-wellness reference. |
|
||||||
|
| Agape | 500K+ | 4.1 | 9.55K | Relationship-wellness reference. |
|
||||||
|
| Couply | 100K+ | 4.4 | 1.95K | Relationship-wellness reference. |
|
||||||
|
| Lovify | 1M+ | 4.7 | 21.7K | Adjacent couples-app reference. |
|
||||||
|
|
||||||
|
Store pages: [Desire](https://play.google.com/store/apps/details?id=com.desireapps.desire),
|
||||||
|
[Paired](https://play.google.com/store/apps/details?id=com.getpaired.app),
|
||||||
|
[Agape](https://play.google.com/store/apps/details?id=com.getdailyagape),
|
||||||
|
[Couply](https://play.google.com/store/apps/details?id=io.couply.android), and
|
||||||
|
[Lovify](https://play.google.com/store/apps/details?id=com.mindsets.lovify). Explicit
|
||||||
|
Apple-only game entries remain qualitative because comparable Play install
|
||||||
|
metadata is unavailable.
|
||||||
|
|
||||||
|
## Target customer
|
||||||
|
|
||||||
|
The primary customer is an adult couple with a viable relationship who wants
|
||||||
|
more novelty, flirtation, or intimacy, but finds direct initiation of an
|
||||||
|
unfamiliar idea awkward. They need privacy around an unmatched interest, control
|
||||||
|
over intensity, and a route from curiosity to a mutually chosen action.
|
||||||
|
|
||||||
|
Initial exclusions are couples seeking clinical repair or coercion intervention,
|
||||||
|
a solitary-arousal or explicit-instruction catalogue, a product that assigns
|
||||||
|
sexual tasks from one partner to the other, and all minor or adult/minor use.
|
||||||
|
This is scope control, not a judgment about those users.
|
||||||
|
|
||||||
|
## Current technical foundation
|
||||||
|
|
||||||
|
| Existing foundation | Exact repository evidence | Product use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Swipe, text answer, audio, photo, and who-likely questions | [Question interaction contract](../question-types.md), [shared type](../../packages/shared/src/types/index.ts) | Private preference selection, written invitations, voice-led prompts, and playful questions. |
|
||||||
|
| Reciprocal match semantics and response summaries | [Question interaction contract](../question-types.md), [match calculation](../../apps/api/src/domains/answers/types.ts) | Reveal only a mutual interest or both-partner response. |
|
||||||
|
| Sender-to-recipient dares with decline, cancel, expiry, and audio/photo proof | [Dares loop](../dares-loop.md), [dare repository](../../apps/api/src/domains/dares/repository.ts) | Existing optional challenge flow, not a shared-action model. |
|
||||||
|
| Couple-shared premium entitlement | [Dares entitlement](../dares-loop.md), [subscription system](../subscription-system.md) | Offer a shared upgrade without paywalling a received invite. |
|
||||||
|
| Dare push payloads and reviewed-dare gating | [Dares safety rules](../dares-loop.md) | Keep dare text out of notification previews and unreviewed dares out of clients. |
|
||||||
|
| JavaScript and asset OTA delivery | [Mobile OTA policy](../mobile-ota.md) | Ship compatible client UI, copy, logic, and assets without a new binary. |
|
||||||
|
|
||||||
|
The [catalogue safety plan](catalog-content-safety-plan.md) defines the intended
|
||||||
|
developer-authored content ceiling: no sexual acts, instructions, fetish content,
|
||||||
|
or material intended for sexual gratification. It states that age confirmation,
|
||||||
|
intensity, premium access, and hidden categories must not override that ceiling.
|
||||||
|
It is future universal question/pack enforcement work. Do not describe it as
|
||||||
|
currently enforced for question recommendation or pack APIs.
|
||||||
|
|
||||||
|
## Product positioning and architecture
|
||||||
|
|
||||||
|
> Sauci helps couples turn unspoken curiosity into things they both want to try.
|
||||||
|
|
||||||
|
Lead with mutuality, privacy, and choice, not content count, coins, or a promise
|
||||||
|
of explicit material. Organise approved content by desired outcome: flirt and
|
||||||
|
anticipate; touch and sensation; take the lead; give up control; tell me
|
||||||
|
privately; try something new; fantasy without assumption; setting and
|
||||||
|
atmosphere; playful roles; long-distance desire; boundaries and comfort;
|
||||||
|
aftercare and reconnection; create your own; and matches for later.
|
||||||
|
|
||||||
|
Developer-authored wording should remain non-graphic and choice-based.
|
||||||
|
Couple-authored detail is UGC, not a policy workaround. The proposed product
|
||||||
|
rule is that new content remains unreviewed and ineligible until a recorded
|
||||||
|
editorial decision allows it; universal question/pack enforcement is not yet
|
||||||
|
current behaviour.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Choose a mood and shared comfort ceiling
|
||||||
|
-> privately answer a short set
|
||||||
|
-> reveal only a mutual interest or both-partner response
|
||||||
|
-> talk, save, or create an optional dare
|
||||||
|
-> complete, defer, decline, or withdraw without penalty
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not disclose which partner chose the lower comfort ceiling. This is a desired
|
||||||
|
future product rule, not an existing session contract. Today the system has a
|
||||||
|
persistent `max_intensity` preference and global selection gates; it does not
|
||||||
|
have a per-session shared comfort record or a mutual private-ceiling protocol.
|
||||||
|
Likewise, a distinct defer, softer alternative, or withdrawal state needs a
|
||||||
|
domain contract rather than a client label alone.
|
||||||
|
|
||||||
|
## One-week delivery plan
|
||||||
|
|
||||||
|
**Current OTA blocker:** the maintained [OTA policy](../mobile-ota.md) says a
|
||||||
|
new store bootstrap build is required before updates can flow. Consequently none
|
||||||
|
of these changes can currently reach users by OTA. After that bootstrap build,
|
||||||
|
client-only prototyping may be OTA-compatible when it stays within the installed
|
||||||
|
native surface. The policy still requires a new store build for native code,
|
||||||
|
dependencies, native configuration, or generated iOS/Android changes.
|
||||||
|
|
||||||
|
Server-side catalogue/configuration activity is separate operational work, not
|
||||||
|
an OTA bundle.
|
||||||
|
|
||||||
|
| Priority | Change | OTA UI/client work | Server-side data/config | Boundary |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Quick Spark prototype | Mood entry, 5-8 card session, completion, and mutuality explainer. | Curate only currently eligible question inventory after its enforcement status is verified. | Client prototype may be OTA-compatible after bootstrap; proper reviewed-question enforcement is future API work. |
|
||||||
|
| 2 | Match-to-shared-action design | Add a non-committing Talk or Save affordance. | Design a match-linked shared-action domain model. | True shared action is API/domain work. Current dares assign a sender and recipient. |
|
||||||
|
| 3 | Shared comfort design | Prototype explanatory UI only. | Persist a per-session private selection and calculate a shared ceiling server-side. | Backend/data work. Current `max_intensity` and global gates are not a session protocol. |
|
||||||
|
| 4 | Tell Me Privately prototype | Curate text/audio presentation with both-partner reveal copy. | Verify question eligibility and review any activated prompts. | Client work may be OTA-compatible after bootstrap; no claim of new enforcement. |
|
||||||
|
| 5 | Defer and withdrawal design | Improve wording only where existing decline/cancel actions remain truthful. | Add distinct defer, softer alternative, and withdrawal states with no-penalty semantics. | Backend/data work, not an OTA-only change. |
|
||||||
|
|
||||||
|
Do not call a change OTA-deliverable until the bootstrap build has shipped and
|
||||||
|
the final diff stays inside the installed native surface. Catalogue activation
|
||||||
|
needs its own review, staged rollout, and rollback plan.
|
||||||
|
|
||||||
|
## Store-policy and UGC boundary
|
||||||
|
|
||||||
|
Google Play prohibits content intended to be sexually gratifying and identifies
|
||||||
|
explicit sexual text and sex guides among common violation types. Apple
|
||||||
|
prohibits overtly sexual or pornographic material. Use the stricter Google
|
||||||
|
ceiling for one cross-platform developer-authored catalogue.
|
||||||
|
|
||||||
|
- [Google Play sexual content policy](https://support.google.com/googleplay/android-developer/answer/9878810)
|
||||||
|
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||||
|
- [Google Play UGC policy](https://support.google.com/googleplay/android-developer/answer/9876937)
|
||||||
|
|
||||||
|
Subscription, an 18+ label, intensity setting, content flag, or private screen
|
||||||
|
is not a policy exception. Paid functionality must be compliant and reviewable.
|
||||||
|
|
||||||
|
Text, audio, photo, custom dare, and chat create UGC exposure. Before expanding
|
||||||
|
adult-oriented user-authored interactions, verify effective reporting and
|
||||||
|
blocking, terms and enforcement, moderation operations, rate limits, safe push
|
||||||
|
previews, and an incident route for non-consensual or illegal content. The
|
||||||
|
catalogue safety plan requires these controls for user-authored fields; their
|
||||||
|
actual implementation status must be checked before marketing relies on them.
|
||||||
|
|
||||||
|
## Gaps and measurement
|
||||||
|
|
||||||
|
Before a broader adult-content launch: complete the universal question/pack
|
||||||
|
`content_status` enforcement specified by the catalogue plan; verify or
|
||||||
|
implement age assurance and accurate store ratings; add private session comfort
|
||||||
|
and durable interest/maybe/boundary states; make withdrawal, deferral, and
|
||||||
|
softer alternatives explicit in a shared-action contract; verify moderation for
|
||||||
|
every UGC surface; and establish reviewed editorial audit for every
|
||||||
|
developer-authored prompt, pack, and dare.
|
||||||
|
|
||||||
|
Measure aggregate, privacy-preserving events only. Do not collect prompt text,
|
||||||
|
response text, audio, photo content, or intimate match detail in analytics.
|
||||||
|
|
||||||
|
| Funnel stage | Measure | Decision |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Entry | Starts by mood and shared ceiling | Which framing gets couples into the loop. |
|
||||||
|
| Participation | Both-partner completion and time to second response | Whether the mode gets reciprocal participation. |
|
||||||
|
| Mutuality | Match rate by reviewed content group and intensity | Whether selection is calibrated. |
|
||||||
|
| Action | Match-to-talk, save, dare-draft, send, and completion rates | Whether discovery creates a wanted next step. |
|
||||||
|
| Safety | Skip, defer, decline, cancel, report, and block rates without content payloads | Whether a category creates pressure or risk. |
|
||||||
|
| Retention | Another short session within 7 and 28 days | Whether the loop survives novelty. |
|
||||||
|
|
||||||
|
Do not optimise only for sends or completions. High decline, report, or
|
||||||
|
withdrawal is a safety signal, not a conversion problem to suppress.
|
||||||
|
|
||||||
|
## Decision gates
|
||||||
|
|
||||||
|
1. Approve the positioning and mutual-match-to-optional-action hypothesis.
|
||||||
|
2. Ship and verify the required OTA bootstrap store build before treating any
|
||||||
|
client change as OTA deployable.
|
||||||
|
3. Split shared action, session comfort, and durable consent states into API and
|
||||||
|
data work rather than representing them as client-only changes.
|
||||||
|
4. Audit age assurance and UGC safeguards before marketing adult-oriented
|
||||||
|
custom media or prompts.
|
||||||
|
5. Complete universal question/pack review enforcement before activating new
|
||||||
|
adult-oriented developer-authored catalogue content.
|
||||||
Reference in new issue
Block a user