mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c904f4e960 | ||
|
|
15c7894bec | ||
|
|
57c7ecebe6 | ||
|
|
17372a9bf0 | ||
|
|
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 | ||
|
|
418dd50306 | ||
|
|
206acbc670 | ||
|
|
ea038c9863 | ||
|
|
a431adad89 | ||
|
|
860986f900 | ||
|
|
439f506fff | ||
|
|
557d78b785 | ||
|
|
87309101ca | ||
|
|
d998e32047 | ||
|
|
7edd957547 | ||
|
|
fdaa8c47bb | ||
|
|
04328fb3a8 | ||
|
|
f373e3beb4 | ||
|
|
ba3b37989f | ||
|
|
583d38eb8c | ||
|
|
d604cd7a11 | ||
|
|
ddf1e33a4e | ||
|
|
da1c310738 | ||
|
|
5c8c9cc9a5 | ||
|
|
c297f03b56 | ||
|
|
c79d993cf5 | ||
|
|
b5e6b70d54 | ||
|
|
2a2cbe9e85 | ||
|
|
eb54aa4daa | ||
|
|
e509ac732f | ||
|
|
a72fe9c06d | ||
|
|
450c527d1e | ||
|
|
6c7219c741 | ||
|
|
b02a105e10 | ||
|
|
f4687f95fe | ||
|
|
53a48ab6d5 | ||
|
|
a6233d0175 | ||
|
|
c776859183 | ||
|
|
cc12f5f0ff | ||
|
|
fe1248c59d | ||
|
|
a06f1b69e8 | ||
|
|
3a019e62d3 | ||
|
|
84712a950e | ||
|
|
d14869d745 | ||
|
|
f1573f4906 | ||
|
|
83df3b45f8 |
No files matched your search
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
id: 2026-09-05-question-type-contract-drift
|
||||||
|
date: 2026-09-05
|
||||||
|
status: promoted
|
||||||
|
scope: question interaction contract documentation
|
||||||
|
evidence: npm run lint:harness and npm run learn:check
|
||||||
|
destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md
|
||||||
|
review_after: 2026-12-05
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
Maintained documentation did not identify the implemented question interaction
|
||||||
|
types, their response payloads, or their matching semantics. The only detailed
|
||||||
|
reference was a historical implementation plan, so content and product work
|
||||||
|
could not rely on maintained documentation.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question
|
||||||
|
renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and
|
||||||
|
`docs/question-selection.md`.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
The interaction contract was added to code without a maintained documentation
|
||||||
|
owner or a verification check linking the document to the canonical union.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Create `docs/question-types.md` as the maintained interaction contract. Require
|
||||||
|
agents and the question-pack workflow to update it with type or semantic
|
||||||
|
changes. Add a harness invariant that compares its type marker to the shared
|
||||||
|
`QuestionType` union.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`npm run lint:harness` reads the shared union and the documentation marker and
|
||||||
|
fails when their ordered type lists differ. `npm run learn:check` validates this
|
||||||
|
promoted learning record and its destination metadata.
|
||||||
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ description: Create or review Sauci question-pack content and its local migratio
|
|||||||
|
|
||||||
Inspect existing packs before generating to avoid duplication.
|
Inspect existing packs before generating to avoid duplication.
|
||||||
|
|
||||||
|
Before creating or editing content that uses a question interaction type, read
|
||||||
|
`docs/question-types.md`. If the type, response payload, configuration, or
|
||||||
|
matching semantics change, update that maintained contract and run
|
||||||
|
`npm run lint:harness` in the same task.
|
||||||
|
|
||||||
- Write proposals, not interview questions; use gender-neutral “your partner”.
|
- Write proposals, not interview questions; use gender-neutral “your partner”.
|
||||||
- Avoid time-bound words such as tonight, now, or today.
|
- Avoid time-bound words such as tonight, now, or today.
|
||||||
- Symmetric activities have no partner text.
|
- Symmetric activities have no partner text.
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
11. Use high-effort reviewers only for the risk triggers in
|
11. Use high-effort reviewers only for the risk triggers in
|
||||||
`docs/agents/routing.md`.
|
`docs/agents/routing.md`.
|
||||||
12. Agents must not use em dash characters in text they add to this repository.
|
12. Agents must not use em dash characters in text they add to this repository.
|
||||||
|
13. Before beginning implementation, tell the user whether the proposed change
|
||||||
|
is likely deliverable through an Expo OTA update, likely requires a new
|
||||||
|
native/store build, or has no applicable OTA path, with a brief reason. If
|
||||||
|
uncertain, state that uncertainty and name the validation needed.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
@@ -87,6 +91,9 @@ environment limitation; do not call it a passing product check.
|
|||||||
## Documentation discipline
|
## Documentation discipline
|
||||||
|
|
||||||
Update the maintained doc when a contract, command, port, or invariant changes.
|
Update the maintained doc when a contract, command, port, or invariant changes.
|
||||||
|
When question interaction types, response payloads, configuration, or match
|
||||||
|
semantics change, update `docs/question-types.md` in the same change and run
|
||||||
|
`npm run lint:harness` to prove its type list matches the shared contract.
|
||||||
Plans and historical notes are evidence, not current architecture. A repeated
|
Plans and historical notes are evidence, not current architecture. A repeated
|
||||||
lesson becomes permanent only after it is reproduced and promoted into a doc,
|
lesson becomes permanent only after it is reproduced and promoted into a doc,
|
||||||
skill, fixture, or executable check.
|
skill, fixture, or executable check.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { featureInterestKey, formatFeatureName, toRecentFeatureInterest } from './featureInterests';
|
||||||
|
|
||||||
|
describe('toRecentFeatureInterest', () => {
|
||||||
|
it('preserves the standalone API feature field for dashboard rendering', () => {
|
||||||
|
const interest = toRecentFeatureInterest({
|
||||||
|
user_id: 'user-1',
|
||||||
|
feature: 'better_chat',
|
||||||
|
created_at: '2026-09-05T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(interest).toEqual({
|
||||||
|
user_id: 'user-1',
|
||||||
|
feature: 'better_chat',
|
||||||
|
created_at: '2026-09-05T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(formatFeatureName(interest.feature)).toBe('Better Chat');
|
||||||
|
expect(featureInterestKey(interest)).toBe('user-1:better_chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface FeatureInterestRow {
|
||||||
|
user_id: string;
|
||||||
|
feature: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentFeatureInterest extends FeatureInterestRow {
|
||||||
|
profile?: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRecentFeatureInterest(row: FeatureInterestRow): RecentFeatureInterest {
|
||||||
|
return {
|
||||||
|
user_id: row.user_id,
|
||||||
|
feature: row.feature,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featureInterestKey(row: Pick<FeatureInterestRow, 'user_id' | 'feature'>): string {
|
||||||
|
return `${row.user_id}:${row.feature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFeatureName(feature: string): string {
|
||||||
|
return feature
|
||||||
|
.split('_')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
@@ -10,36 +10,22 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
|
|||||||
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
|
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
|
import {
|
||||||
|
formatFeatureName,
|
||||||
|
featureInterestKey,
|
||||||
|
toRecentFeatureInterest,
|
||||||
|
type FeatureInterestRow,
|
||||||
|
type RecentFeatureInterest,
|
||||||
|
} from '@/lib/featureInterests';
|
||||||
|
|
||||||
type FeatureInterestChartPoint = { date: string; count: number };
|
type FeatureInterestChartPoint = { date: string; count: number };
|
||||||
|
|
||||||
type RecentFeatureInterest = {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
feature_name: string;
|
|
||||||
created_at: string;
|
|
||||||
profile?: {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
email: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type FeatureInterestCountsRow = {
|
type FeatureInterestCountsRow = {
|
||||||
feature_name: string;
|
feature_name: string;
|
||||||
opt_in_count: number;
|
opt_in_count: number;
|
||||||
opt_in_count_last_7_days: number;
|
opt_in_count_last_7_days: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatFeatureName(featureName: string) {
|
|
||||||
return featureName
|
|
||||||
.split('_')
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||||
@@ -95,7 +81,7 @@ export function DashboardPage() {
|
|||||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||||
: Promise.resolve({ data: [] }),
|
: Promise.resolve({ data: [] }),
|
||||||
canViewUsers
|
canViewUsers
|
||||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
|
||||||
: Promise.resolve({ data: [] }),
|
: Promise.resolve({ data: [] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -158,12 +144,7 @@ export function DashboardPage() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({
|
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
|
||||||
id: item.id,
|
|
||||||
user_id: item.user_id,
|
|
||||||
feature_name: item.feature_name,
|
|
||||||
created_at: item.created_at,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (canViewUsers && recentActivity.length > 0) {
|
if (canViewUsers && recentActivity.length > 0) {
|
||||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||||
@@ -579,14 +560,14 @@ export function DashboardPage() {
|
|||||||
if (item.profile?.name && item.profile.email) {
|
if (item.profile?.name && item.profile.email) {
|
||||||
subtitleParts.push(item.profile.email);
|
subtitleParts.push(item.profile.email);
|
||||||
}
|
}
|
||||||
subtitleParts.push(formatFeatureName(item.feature_name));
|
subtitleParts.push(formatFeatureName(item.feature));
|
||||||
|
|
||||||
const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
|
const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
|
||||||
.charAt(0)
|
.charAt(0)
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
|
<div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={item.profile?.avatar_url || undefined} />
|
<AvatarImage src={item.profile?.avatar_url || undefined} />
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
-- Initial editorial dare catalogue. Catalogue IDs are deterministic so the seed
|
||||||
|
-- is replay-safe. Only rows that passed the universal store-safety review are
|
||||||
|
-- public and `allowed`; all other editorial inventory remains fail-closed.
|
||||||
|
|
||||||
|
WITH seed (
|
||||||
|
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||||
|
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason
|
||||||
|
) AS (VALUES
|
||||||
|
('1f5e0000-0000-4000-8000-100000000001', 'Little Things', 'Small gestures that make your partner feel seen.', '💌', false, true, false, 10, 1, 1, 1.00, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000002', 'Make Me Laugh', 'Silly challenges for a shared laugh.', '😄', false, true, false, 20, 1, 2, 1.33, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000003', 'Sweet & Romantic', 'Thoughtful dares for feeling close.', '💕', false, true, false, 30, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual romantic content'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000004', 'Surprise Me', 'Plan a little surprise for your partner.', '🎁', true, true, false, 40, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000005', 'Flirty Messages', 'Private messages with a little spark.', '💬', true, false, false, 50, 2, 3, 2.47, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000006', 'Long-Distance Heat', 'Private dares for partners apart.', '📱', true, false, true, 60, 2, 3, 2.67, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000007', 'Risky Photos & Audio', 'Private photo and audio dares.', '📸', true, false, true, 70, 3, 4, 3.53, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000008', 'Hands On', 'Private in-person intimacy dares.', '🫶', true, false, true, 80, 3, 4, 3.40, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000009', 'After Dark', 'Private adult intimacy dares.', '🌙', true, false, true, 90, 4, 4, 4.00, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-100000000010', 'Power Play', 'Private adult power-play dares.', '🔒', true, false, true, 100, 5, 5, 5.00, 'unreviewed', 'Private mature editorial inventory')
|
||||||
|
), upsert AS (
|
||||||
|
INSERT INTO dare_packs (
|
||||||
|
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||||
|
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason,
|
||||||
|
content_reviewed_at
|
||||||
|
)
|
||||||
|
SELECT id::uuid, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||||
|
min_intensity, max_intensity, avg_intensity::numeric(3, 2),
|
||||||
|
content_status::content_review_status, content_review_reason,
|
||||||
|
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
|
||||||
|
FROM seed
|
||||||
|
ON CONFLICT (id) DO UPDATE SET id = dare_packs.id
|
||||||
|
WHERE (dare_packs.name, dare_packs.description, dare_packs.icon,
|
||||||
|
dare_packs.is_premium, dare_packs.is_public, dare_packs.is_explicit,
|
||||||
|
dare_packs.sort_order, dare_packs.category_id, dare_packs.min_intensity,
|
||||||
|
dare_packs.max_intensity, dare_packs.avg_intensity, dare_packs.content_status,
|
||||||
|
dare_packs.content_review_reason, dare_packs.content_reviewed_at,
|
||||||
|
dare_packs.content_reviewed_by)
|
||||||
|
IS NOT DISTINCT FROM
|
||||||
|
(EXCLUDED.name, EXCLUDED.description, EXCLUDED.icon, EXCLUDED.is_premium,
|
||||||
|
EXCLUDED.is_public, EXCLUDED.is_explicit, EXCLUDED.sort_order,
|
||||||
|
EXCLUDED.category_id, EXCLUDED.min_intensity, EXCLUDED.max_intensity,
|
||||||
|
EXCLUDED.avg_intensity, EXCLUDED.content_status,
|
||||||
|
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
|
||||||
|
EXCLUDED.content_reviewed_by)
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||||
|
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
WITH seed (id, pack_id, text, intensity, content_status, content_review_reason) AS (VALUES
|
||||||
|
-- Little Things
|
||||||
|
('1f5e0000-0000-4000-8000-000000000001', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a photo of something that reminded you of them.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000002', '1f5e0000-0000-4000-8000-100000000001', 'Hide a short note somewhere your partner will unexpectedly find it.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000003', '1f5e0000-0000-4000-8000-100000000001', 'Bring your partner their favourite snack without asking what they want.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000004', '1f5e0000-0000-4000-8000-100000000001', 'Take over the household job your partner hates most.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000005', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a drink exactly how they like it.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000006', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a compliment that has nothing to do with their appearance.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000007', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a proper uninterrupted hug.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000008', '1f5e0000-0000-4000-8000-100000000001', 'Choose a shared photo and tell your partner why you still love that moment.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000009', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a three-song playlist for their current mood.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000010', '1f5e0000-0000-4000-8000-100000000001', 'Leave your partner a voice note they can replay when they are having a bad day.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000011', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a homemade voucher for one chore-free evening.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000012', '1f5e0000-0000-4000-8000-100000000001', 'Put your phone away and give your partner your full attention for thirty minutes.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000013', '1f5e0000-0000-4000-8000-100000000001', 'Tell your partner about the first moment you realised they were special.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000014', '1f5e0000-0000-4000-8000-100000000001', 'Fix one small annoyance your partner has been putting off.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000015', '1f5e0000-0000-4000-8000-100000000001', 'Let your partner choose what you watch, play, or listen to together.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||||
|
-- Make Me Laugh
|
||||||
|
('1f5e0000-0000-4000-8000-000000000016', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner the worst selfie you can take.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000017', '1f5e0000-0000-4000-8000-100000000002', 'Record a ten-second impression of your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000018', '1f5e0000-0000-4000-8000-100000000002', 'Draw your partner from memory and send them the result.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000019', '1f5e0000-0000-4000-8000-100000000002', 'Make a meme about your relationship.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000020', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner your cheesiest pickup line.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000021', '1f5e0000-0000-4000-8000-100000000002', 'Invent a ridiculous new nickname for your partner and use it until the dare expires.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000022', '1f5e0000-0000-4000-8000-100000000002', 'Record a dramatic advert explaining why someone should date your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000023', '1f5e0000-0000-4000-8000-100000000002', 'Tell your partner a painfully bad joke without laughing.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000024', '1f5e0000-0000-4000-8000-100000000002', 'Recreate one of your partner''s photos as badly as possible.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000025', '1f5e0000-0000-4000-8000-100000000002', 'Describe your relationship using only five emojis.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000026', '1f5e0000-0000-4000-8000-100000000002', 'Perform a thirty-second dance chosen by your partner.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000027', '1f5e0000-0000-4000-8000-100000000002', 'Wear a ridiculous accessory until your partner releases you from the dare.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000028', '1f5e0000-0000-4000-8000-100000000002', 'Serenade your partner with the least romantic song you can find.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000029', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner an embarrassing but harmless confession.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000030', '1f5e0000-0000-4000-8000-100000000002', 'Act out how you behaved on your first date.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||||
|
-- Sweet & Romantic
|
||||||
|
('1f5e0000-0000-4000-8000-000000000031', '1f5e0000-0000-4000-8000-100000000003', 'Write your partner a love note by hand.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000032', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a song that says what you struggle to put into words.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000033', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner three small things they do that make your life better.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000034', '1f5e0000-0000-4000-8000-100000000003', 'Plan an at-home date and keep the details secret.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000035', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a slow dance without waiting for the perfect song.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000036', '1f5e0000-0000-4000-8000-100000000003', 'Recreate one detail from your first date together.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000037', '1f5e0000-0000-4000-8000-100000000003', 'Kiss your partner like you have not seen them for a month.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000038', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a voice note describing your favourite memory of them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000039', '1f5e0000-0000-4000-8000-100000000003', 'Prepare your partner''s side of the bed exactly how they like it.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000040', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner five kisses, each in a different place.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000041', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner the quality you find most attractive in them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000042', '1f5e0000-0000-4000-8000-100000000003', 'Plan a mystery date and give your partner only one clue.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000043', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a compliment they will still remember next week.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000044', '1f5e0000-0000-4000-8000-100000000003', 'Read your partner a message you sent during the early days of your relationship.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000045', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner ten minutes of affection without looking at either of your phones.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||||
|
-- Surprise Me
|
||||||
|
('1f5e0000-0000-4000-8000-000000000046', '1f5e0000-0000-4000-8000-100000000004', 'Leave your partner''s favourite treat somewhere they will discover it.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000047', '1f5e0000-0000-4000-8000-100000000004', 'Complete one job your partner expects to do themselves.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000048', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner an unexpected food delivery.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000049', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a mystery date and tell your partner only when to be ready.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000050', '1f5e0000-0000-4000-8000-100000000004', 'Hide three clues leading your partner to a small surprise.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000051', '1f5e0000-0000-4000-8000-100000000004', 'Take your partner somewhere neither of you has visited before.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000052', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner flowers or their preferred equivalent for no particular reason.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000053', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner coordinates and ask them to meet you there.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000054', '1f5e0000-0000-4000-8000-100000000004', 'Create a playlist that reveals what you have planned.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000055', '1f5e0000-0000-4000-8000-100000000004', 'Recreate your partner''s ideal lazy morning.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000056', '1f5e0000-0000-4000-8000-100000000004', 'Put together a date using only things already in your home.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000057', '1f5e0000-0000-4000-8000-100000000004', 'Choose an outfit for your partner from clothes they already own.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000058', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner an envelope they are not allowed to open until the dare expires.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000059', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a surprise based on something your partner mentioned weeks ago.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000060', '1f5e0000-0000-4000-8000-100000000004', 'Turn an ordinary meal into a date without warning your partner first.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||||
|
-- Flirty Messages
|
||||||
|
('1f5e0000-0000-4000-8000-000000000061', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three messages that become progressively flirtier.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000062', '1f5e0000-0000-4000-8000-100000000005', 'Record yourself saying your partner''s name in the most seductive way you can.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000063', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a selfie wearing something you know they like.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000064', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner where you want them to kiss you.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000065', '1f5e0000-0000-4000-8000-100000000005', 'Describe the first thing you notice when your partner enters a room.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000066', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a compliment you would not say in front of anyone else.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000067', '1f5e0000-0000-4000-8000-100000000005', 'Invent a secret phrase that means you want your partner.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000068', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a message designed to make them blush.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000069', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner what you would do if you were alone together.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000070', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three words designed to distract them.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000071', '1f5e0000-0000-4000-8000-100000000005', 'Record a voice note telling your partner what you want later.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000072', '1f5e0000-0000-4000-8000-100000000005', 'Send a close-up photo and make your partner guess what they are looking at.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000073', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner which item of their clothing you most want to remove.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000074', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner one instruction to follow when you next meet.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000075', '1f5e0000-0000-4000-8000-100000000005', 'Describe one fantasy in a single sentence without using vague words.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||||
|
-- Long-Distance Heat
|
||||||
|
('1f5e0000-0000-4000-8000-000000000076', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo showing exactly what you are doing.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000077', '1f5e0000-0000-4000-8000-100000000006', 'Record a goodnight message your partner will want to replay.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000078', '1f5e0000-0000-4000-8000-100000000006', 'Plan a video-call date and send your partner a dress code.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000079', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo of the empty space where you wish they were.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000080', '1f5e0000-0000-4000-8000-100000000006', 'Order your partner a surprise they can enjoy during your next call.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000081', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a risky selfie within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000082', '1f5e0000-0000-4000-8000-100000000006', 'Record a voice note telling your partner exactly how much you miss their touch.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000083', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo from bed without adding any explanation.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000084', '1f5e0000-0000-4000-8000-100000000006', 'Describe what you would do if your partner appeared beside you.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000085', '1f5e0000-0000-4000-8000-100000000006', 'Give your partner a private fashion show over video.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000086', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a sequence of photos that gradually reveal more.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000087', '1f5e0000-0000-4000-8000-100000000006', 'Record yourself whispering what you want your partner to do when you reunite.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000088', '1f5e0000-0000-4000-8000-100000000006', 'Let your partner choose what you wear during your next private call.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000089', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo they can use as their private wallpaper.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000090', '1f5e0000-0000-4000-8000-100000000006', 'End your next call with a promise your partner can hold you to.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
-- Risky Photos & Audio
|
||||||
|
('1f5e0000-0000-4000-8000-000000000091', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo captioned "Only for you."', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000092', '1f5e0000-0000-4000-8000-100000000007', 'Take a silhouette photo that leaves your partner guessing.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000093', '1f5e0000-0000-4000-8000-100000000007', 'Record a voice note describing what you are wearing.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000094', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo that reveals just enough to make them curious.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000095', '1f5e0000-0000-4000-8000-100000000007', 'Take a photo from your partner''s favourite angle.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000096', '1f5e0000-0000-4000-8000-100000000007', 'Record a risky audio clip for your partner within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000097', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo showing what you plan to wear underneath your clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000098', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a topless photo they have not seen before.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000099', '1f5e0000-0000-4000-8000-100000000007', 'Record yourself describing exactly what you want your partner to do to you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000100', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a nude using lighting or shadows creatively.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000101', '1f5e0000-0000-4000-8000-100000000007', 'Record the sound you make when your partner touches you properly.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000102', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a short striptease video.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000103', '1f5e0000-0000-4000-8000-100000000007', 'Take a nude photo without showing your face.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000104', '1f5e0000-0000-4000-8000-100000000007', 'Record a video showing your partner how badly you want them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000105', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner the most explicit photo you are both comfortable keeping.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
-- Hands On
|
||||||
|
('1f5e0000-0000-4000-8000-000000000106', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a ten-minute massage.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000107', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner in five places without kissing their lips.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000108', '1f5e0000-0000-4000-8000-100000000008', 'Trace your fingertips over your partner''s body for five minutes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000109', '1f5e0000-0000-4000-8000-100000000008', 'Sit on your partner''s lap and kiss them slowly.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000110', '1f5e0000-0000-4000-8000-100000000008', 'Blindfold your partner and feed them something they enjoy.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000111', '1f5e0000-0000-4000-8000-100000000008', 'Let your partner choose where you kiss them.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000112', '1f5e0000-0000-4000-8000-100000000008', 'Tease your partner for ten minutes without touching beneath their clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000113', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a massage and stop exactly when they want more.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000114', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner''s neck until they ask you to stop.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000115', '1f5e0000-0000-4000-8000-100000000008', 'Remove one item of your partner''s clothing using only one hand.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000116', '1f5e0000-0000-4000-8000-100000000008', 'Guide your partner''s hand to where you want it.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000117', '1f5e0000-0000-4000-8000-100000000008', 'Undress your partner as slowly as possible.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000118', '1f5e0000-0000-4000-8000-100000000008', 'Join your partner in the shower.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000119', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your way down your partner''s body and stop just before reaching their most sensitive spot.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000120', '1f5e0000-0000-4000-8000-100000000008', 'Pleasure your partner for ten minutes without letting them return the favour.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
-- After Dark
|
||||||
|
('1f5e0000-0000-4000-8000-000000000121', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly what you want, then do it together if they want it too.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000122', '1f5e0000-0000-4000-8000-100000000009', 'Give your partner oral sex.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000123', '1f5e0000-0000-4000-8000-100000000009', 'Have sex in a position chosen by your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000124', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate while your partner watches.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000125', '1f5e0000-0000-4000-8000-100000000009', 'Watch your partner masturbate without touching them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000126', '1f5e0000-0000-4000-8000-100000000009', 'Use a sex toy on your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000127', '1f5e0000-0000-4000-8000-100000000009', 'Let your partner use a sex toy on you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000128', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate together while watching each other.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000129', '1f5e0000-0000-4000-8000-100000000009', 'Blindfold your partner and pleasure them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000130', '1f5e0000-0000-4000-8000-100000000009', 'Have sex without completely removing your clothes.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000131', '1f5e0000-0000-4000-8000-100000000009', 'Take your partner somewhere private for a quickie.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000132', '1f5e0000-0000-4000-8000-100000000009', 'Act out a fantasy your partner has previously shared with you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000133', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly how to touch you while they do it.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000134', '1f5e0000-0000-4000-8000-100000000009', 'Spend twenty minutes pleasuring each other without penetration.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000135', '1f5e0000-0000-4000-8000-100000000009', 'Make your partner orgasm without using your hands.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
-- Power Play
|
||||||
|
('1f5e0000-0000-4000-8000-000000000136', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner three rules to follow until the dare expires.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000137', '1f5e0000-0000-4000-8000-100000000010', 'Kneel in front of your partner and wait for their first instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000138', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner restrain your wrists using an agreed quick-release restraint.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000139', '1f5e0000-0000-4000-8000-100000000010', 'Wear a blindfold and follow three instructions from your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000140', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner an agreed number of strikes using an agreed implement and intensity.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000141', '1f5e0000-0000-4000-8000-100000000010', 'Act as your partner''s submissive for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000142', '1f5e0000-0000-4000-8000-100000000010', 'Take control of your partner for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000143', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner choose your outfit, position, and posture.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000144', '1f5e0000-0000-4000-8000-100000000010', 'Send your partner a voice note promising to obey one agreed instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000145', '1f5e0000-0000-4000-8000-100000000010', 'Edge your partner and stop before they orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000146', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner decide when you are allowed to orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000147', '1f5e0000-0000-4000-8000-100000000010', 'Wear a collar or agreed symbol of submission for your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000148', '1f5e0000-0000-4000-8000-100000000010', 'Serve your partner without speaking for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000149', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner control a remote toy while you remain somewhere private.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||||
|
('1f5e0000-0000-4000-8000-000000000150', '1f5e0000-0000-4000-8000-100000000010', 'Finish the dare by giving your partner the aftercare they ask for.', 5, 'unreviewed', 'Private mature editorial inventory')
|
||||||
|
), upsert AS (
|
||||||
|
INSERT INTO dares (
|
||||||
|
id, pack_id, text, intensity, content_status, content_review_reason,
|
||||||
|
content_reviewed_at
|
||||||
|
)
|
||||||
|
SELECT id::uuid, pack_id::uuid, text, intensity,
|
||||||
|
content_status::content_review_status, content_review_reason,
|
||||||
|
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
|
||||||
|
FROM seed
|
||||||
|
ON CONFLICT (id) DO UPDATE SET id = dares.id
|
||||||
|
WHERE (dares.pack_id, dares.text, dares.intensity, dares.suggested_duration_hours,
|
||||||
|
dares.content_status, dares.content_review_reason, dares.content_reviewed_at,
|
||||||
|
dares.content_reviewed_by)
|
||||||
|
IS NOT DISTINCT FROM
|
||||||
|
(EXCLUDED.pack_id, EXCLUDED.text, EXCLUDED.intensity,
|
||||||
|
EXCLUDED.suggested_duration_hours, EXCLUDED.content_status,
|
||||||
|
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
|
||||||
|
EXCLUDED.content_reviewed_by)
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||||
|
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- A migration has no authenticated human reviewer. Record the system seed as the
|
||||||
|
-- actor-less, timestamped review decision the schema explicitly permits rather than
|
||||||
|
-- fabricating a profile UUID. The entries remain durable audit provenance.
|
||||||
|
WITH seed (id, entity_type, entity_id, previous_status, new_status, reason, created_at) AS (
|
||||||
|
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||||
|
'dare_packs'::content_entity_type, id, 'unreviewed'::content_review_status,
|
||||||
|
'allowed'::content_review_status, content_review_reason,
|
||||||
|
'2026-09-05T16:00:00Z'::timestamptz
|
||||||
|
FROM dare_packs
|
||||||
|
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||||
|
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||||
|
'dares'::content_entity_type, id, 'unreviewed'::content_review_status,
|
||||||
|
'allowed'::content_review_status, content_review_reason,
|
||||||
|
'2026-09-05T16:00:00Z'::timestamptz
|
||||||
|
FROM dares
|
||||||
|
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||||
|
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||||
|
), upsert AS (
|
||||||
|
INSERT INTO content_reviews (
|
||||||
|
id, entity_type, entity_id, previous_status, new_status, reason, changed_by, created_at
|
||||||
|
)
|
||||||
|
SELECT id::uuid, entity_type::content_entity_type, entity_id::uuid,
|
||||||
|
previous_status::content_review_status, new_status::content_review_status,
|
||||||
|
reason, null::uuid, created_at::timestamptz
|
||||||
|
FROM seed
|
||||||
|
ON CONFLICT (id) DO UPDATE SET id = content_reviews.id
|
||||||
|
WHERE (content_reviews.entity_type, content_reviews.entity_id,
|
||||||
|
content_reviews.previous_status, content_reviews.new_status,
|
||||||
|
content_reviews.reason, content_reviews.changed_by,
|
||||||
|
content_reviews.created_at)
|
||||||
|
IS NOT DISTINCT FROM
|
||||||
|
(EXCLUDED.entity_type, EXCLUDED.entity_id, EXCLUDED.previous_status,
|
||||||
|
EXCLUDED.new_status, EXCLUDED.reason, EXCLUDED.changed_by,
|
||||||
|
EXCLUDED.created_at)
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||||
|
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM content_reviews r
|
||||||
|
WHERE (r.entity_type = 'dare_packs' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid AND '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||||
|
OR (r.entity_type = 'dares' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid AND '1f5e0000-0000-4000-8000-000000000060'::uuid)
|
||||||
|
GROUP BY r.entity_type, r.entity_id
|
||||||
|
HAVING count(*) <> 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'catalogue seed review provenance is not one-to-one';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
WITH expected (id, entity_type, entity_id, reason) AS (
|
||||||
|
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||||
|
'dare_packs'::content_entity_type, id, content_review_reason
|
||||||
|
FROM dare_packs
|
||||||
|
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||||
|
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||||
|
'dares'::content_entity_type, id, content_review_reason
|
||||||
|
FROM dares
|
||||||
|
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||||
|
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||||
|
)
|
||||||
|
SELECT 1
|
||||||
|
FROM expected e
|
||||||
|
LEFT JOIN content_reviews r
|
||||||
|
ON r.entity_type = e.entity_type AND r.entity_id = e.entity_id
|
||||||
|
WHERE (r.id, r.previous_status, r.new_status, r.reason, r.changed_by,
|
||||||
|
r.created_at)
|
||||||
|
IS DISTINCT FROM
|
||||||
|
(e.id, 'unreviewed'::content_review_status,
|
||||||
|
'allowed'::content_review_status, e.reason, null::uuid,
|
||||||
|
'2026-09-05T16:00:00Z'::timestamptz)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'catalogue seed review provenance does not match the system seed';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -162,6 +162,13 @@
|
|||||||
"when": 1788168150000,
|
"when": 1788168150000,
|
||||||
"tag": "0022_solo_sealed_answers",
|
"tag": "0022_solo_sealed_answers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788624000000,
|
||||||
|
"tag": "0023_dare_catalogue_150",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { readdir, readFile } from 'node:fs/promises';
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||||
@@ -82,6 +85,133 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
|||||||
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seeds the 150-item editorial catalogue with fail-closed review states', async () => {
|
||||||
|
const seeded = await pool.query<{
|
||||||
|
name: string; is_premium: boolean; is_public: boolean; is_explicit: boolean;
|
||||||
|
content_status: string; min_intensity: number; max_intensity: number; avg_intensity: string; dare_count: string;
|
||||||
|
}>(`
|
||||||
|
select dp.name, dp.is_premium, dp.is_public, dp.is_explicit, dp.content_status,
|
||||||
|
dp.min_intensity, dp.max_intensity, dp.avg_intensity::text,
|
||||||
|
count(d.id)::text as dare_count
|
||||||
|
from dare_packs dp
|
||||||
|
join dares d on d.pack_id = dp.id
|
||||||
|
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-100000000010'::uuid
|
||||||
|
group by dp.id
|
||||||
|
order by dp.sort_order
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(seeded.rows).toEqual([
|
||||||
|
{ name: 'Little Things', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 1, avg_intensity: '1.00', dare_count: '15' },
|
||||||
|
{ name: 'Make Me Laugh', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.33', dare_count: '15' },
|
||||||
|
{ name: 'Sweet & Romantic', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
|
||||||
|
{ name: 'Surprise Me', is_premium: true, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
|
||||||
|
{ name: 'Flirty Messages', is_premium: true, is_public: false, is_explicit: false, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.47', dare_count: '15' },
|
||||||
|
{ name: 'Long-Distance Heat', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.67', dare_count: '15' },
|
||||||
|
{ name: 'Risky Photos & Audio', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.53', dare_count: '15' },
|
||||||
|
{ name: 'Hands On', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.40', dare_count: '15' },
|
||||||
|
{ name: 'After Dark', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 4, max_intensity: 4, avg_intensity: '4.00', dare_count: '15' },
|
||||||
|
{ name: 'Power Play', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 5, max_intensity: 5, avg_intensity: '5.00', dare_count: '15' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const distribution = await pool.query<{
|
||||||
|
total: string; free: string; premium: string; allowed: string; unreviewed: string; duplicate_texts: string; stat_mismatches: string;
|
||||||
|
}>(`
|
||||||
|
select count(*)::text as total,
|
||||||
|
count(*) filter (where not dp.is_premium)::text as free,
|
||||||
|
count(*) filter (where dp.is_premium)::text as premium,
|
||||||
|
count(*) filter (where d.content_status = 'allowed')::text as allowed,
|
||||||
|
count(*) filter (where d.content_status = 'unreviewed')::text as unreviewed,
|
||||||
|
(select count(*)::text from (
|
||||||
|
select text from dares
|
||||||
|
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-000000000150'::uuid
|
||||||
|
group by text having count(*) > 1
|
||||||
|
) duplicates) as duplicate_texts,
|
||||||
|
(select count(*)::text from (
|
||||||
|
select dp.id
|
||||||
|
from dare_packs dp
|
||||||
|
join dares expected on expected.pack_id = dp.id
|
||||||
|
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-100000000010'::uuid
|
||||||
|
group by dp.id
|
||||||
|
having dp.min_intensity is distinct from min(expected.intensity)
|
||||||
|
or dp.max_intensity is distinct from max(expected.intensity)
|
||||||
|
or dp.avg_intensity is distinct from round(avg(expected.intensity)::numeric, 2)
|
||||||
|
) mismatches) as stat_mismatches
|
||||||
|
from dares d
|
||||||
|
join dare_packs dp on dp.id = d.pack_id
|
||||||
|
where d.id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-000000000150'::uuid
|
||||||
|
`);
|
||||||
|
expect(distribution.rows[0]).toEqual({ total: '150', free: '45', premium: '105', allowed: '60', unreviewed: '90', duplicate_texts: '0', stat_mismatches: '0' });
|
||||||
|
|
||||||
|
const provenance = await pool.query<{ reviewed_rows: string; review_records: string; attributed_records: string }>(`
|
||||||
|
select
|
||||||
|
(select count(*)::text from (
|
||||||
|
select content_reviewed_at
|
||||||
|
from dare_packs
|
||||||
|
where id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||||
|
union all
|
||||||
|
select content_reviewed_at
|
||||||
|
from dares
|
||||||
|
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||||
|
and '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||||
|
) reviewed where content_reviewed_at = '2026-09-05T16:00:00Z'::timestamptz)::text as reviewed_rows,
|
||||||
|
(select count(*)::text from content_reviews
|
||||||
|
where (entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||||
|
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid)) as review_records,
|
||||||
|
(select count(*)::text from content_reviews
|
||||||
|
where ((entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||||
|
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid))
|
||||||
|
and changed_by is not null) as attributed_records
|
||||||
|
`);
|
||||||
|
expect(provenance.rows[0]).toEqual({ reviewed_rows: '64', review_records: '64', attributed_records: '0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is discovered by Drizzle and fails closed on a conflicting catalogue ID', async () => {
|
||||||
|
const migrationSchema = `dare_migrator_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
await admin.query(`create schema "${migrationSchema}"`);
|
||||||
|
const migrationUrl = new URL(url!);
|
||||||
|
migrationUrl.searchParams.set('options', `-c search_path=${migrationSchema}`);
|
||||||
|
const migrationPool = new Pool({ connectionString: migrationUrl.toString() });
|
||||||
|
const dir = new URL('../drizzle/', import.meta.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(drizzle(migrationPool), {
|
||||||
|
migrationsFolder: fileURLToPath(dir),
|
||||||
|
migrationsSchema: migrationSchema,
|
||||||
|
});
|
||||||
|
const discovered = await migrationPool.query<{ count: string }>(
|
||||||
|
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
|
||||||
|
);
|
||||||
|
expect(discovered.rows[0]?.count).toBe('150');
|
||||||
|
|
||||||
|
const seed = await readFile(new URL('0023_dare_catalogue_150.sql', dir), 'utf8');
|
||||||
|
const applySeed = async () => {
|
||||||
|
for (const statement of seed.split('--> statement-breakpoint')) {
|
||||||
|
if (statement.trim()) await migrationPool.query(statement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await applySeed();
|
||||||
|
await migrationPool.query(
|
||||||
|
"update content_reviews set reason = 'Conflicting review' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||||
|
);
|
||||||
|
await expect(applySeed()).rejects.toThrow(/division by zero|review provenance/);
|
||||||
|
await migrationPool.query(
|
||||||
|
"update content_reviews set reason = 'Reviewed with Little Things pack' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||||
|
);
|
||||||
|
await migrationPool.query(
|
||||||
|
"update dares set text = 'Conflicting dare' where id = '1f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||||
|
);
|
||||||
|
await expect(applySeed()).rejects.toThrow(/division by zero/);
|
||||||
|
} finally {
|
||||||
|
await migrationPool.end();
|
||||||
|
await admin.query(`drop schema if exists "${migrationSchema}" cascade`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||||
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
interface JournalEntry {
|
||||||
|
idx: number;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Drizzle migration journal', () => {
|
||||||
|
it('lists every ordered SQL migration exactly once', async () => {
|
||||||
|
const migrationsDirectory = new URL('../drizzle/', import.meta.url);
|
||||||
|
const journal = JSON.parse(await readFile(new URL('meta/_journal.json', migrationsDirectory), 'utf8')) as {
|
||||||
|
entries: JournalEntry[];
|
||||||
|
};
|
||||||
|
const migrationFiles = (await readdir(migrationsDirectory))
|
||||||
|
.filter((name) => name.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
const journalFiles = journal.entries.map((entry) => `${entry.tag}.sql`);
|
||||||
|
|
||||||
|
expect(journal.entries.map((entry) => entry.idx)).toEqual([...journal.entries.keys()]);
|
||||||
|
expect(new Set(journalFiles).size).toBe(journalFiles.length);
|
||||||
|
expect(journalFiles).toEqual(migrationFiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
+33
-46
@@ -21,7 +21,7 @@ import {
|
|||||||
handleNotificationResponse,
|
handleNotificationResponse,
|
||||||
} from "../src/lib/notifications";
|
} from "../src/lib/notifications";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
// Suppress useLayoutEffect warning on server-side rendering
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
@@ -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
|
||||||
@@ -50,7 +51,7 @@ import * as Linking from "expo-linking";
|
|||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { authClient } from "../src/lib/authClient";
|
import { authClient } from "../src/lib/authClient";
|
||||||
import { colors, spacing, radius, typography } from "../src/theme";
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning";
|
||||||
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
||||||
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
import { needsOnboarding } from "../src/constants/onboarding";
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
@@ -58,18 +59,17 @@ import { needsOnboarding } from "../src/constants/onboarding";
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
|
const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
const analyticsInitialized = useRef(false);
|
const analyticsInitialized = useRef(false);
|
||||||
|
|
||||||
const [showGuestWarning, setShowGuestWarning] = useState(false);
|
const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({
|
||||||
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
isAuthenticated,
|
||||||
|
isAnonymous,
|
||||||
const dismissGuestWarning = async () => {
|
userId: user?.id,
|
||||||
await markGuestAccountWarningSeen();
|
pathname,
|
||||||
setShowGuestWarning(false);
|
});
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Initialize analytics after native modules are ready
|
// Initialize analytics after native modules are ready
|
||||||
@@ -89,22 +89,6 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// Warn guest users (first time only) that accounts are not recoverable
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated || !isAnonymous) return;
|
|
||||||
if (checkingGuestWarning) return;
|
|
||||||
if (pathname?.startsWith("/(auth)")) return;
|
|
||||||
|
|
||||||
setCheckingGuestWarning(true);
|
|
||||||
hasSeenGuestAccountWarning()
|
|
||||||
.then((seen) => {
|
|
||||||
if (!seen) {
|
|
||||||
setShowGuestWarning(true);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => setCheckingGuestWarning(false));
|
|
||||||
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
|
|
||||||
|
|
||||||
// Track warm starts when app comes back from background
|
// Track warm starts when app comes back from background
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
||||||
@@ -129,14 +113,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 +254,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={{
|
||||||
@@ -305,22 +296,18 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
<View style={styles.guestWarningActions}>
|
<View style={styles.guestWarningActions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.guestWarningPrimary}
|
style={styles.guestWarningPrimary}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await markGuestAccountWarningSeen();
|
await dismissGuestWarning();
|
||||||
setShowGuestWarning(false);
|
router.push("/(app)/settings/save-account" as any);
|
||||||
router.push("/(app)/settings/save-account" as any);
|
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
|
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.guestWarningSecondary}
|
style={styles.guestWarningSecondary}
|
||||||
onPress={async () => {
|
onPress={dismissGuestWarning}
|
||||||
await markGuestAccountWarningSeen();
|
|
||||||
setShowGuestWarning(false);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
||||||
|
|||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
|
||||||
|
import { useGuestAccountWarning } from "@/hooks/useGuestAccountWarning";
|
||||||
|
|
||||||
|
jest.mock("@/lib/guestAccountWarningSeen", () => ({
|
||||||
|
hasSeenGuestAccountWarning: jest.fn(),
|
||||||
|
markGuestAccountWarningSeen: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
isAnonymous: true,
|
||||||
|
userId: "guest-1",
|
||||||
|
pathname: "/(app)/pairing",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useGuestAccountWarning", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(hasSeenGuestAccountWarning).mockReset();
|
||||||
|
jest.mocked(markGuestAccountWarningSeen).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reopen after a dismissal while the durable write is pending", async () => {
|
||||||
|
let finishMarking: (() => void) | undefined;
|
||||||
|
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
|
||||||
|
jest.mocked(markGuestAccountWarningSeen).mockImplementation(() => new Promise<void>((resolve) => {
|
||||||
|
finishMarking = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
|
||||||
|
let dismissal: Promise<void> | undefined;
|
||||||
|
act(() => {
|
||||||
|
dismissal = result.current.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.visible).toBe(false);
|
||||||
|
|
||||||
|
rerender({ ...options, pathname: "/(app)/settings" });
|
||||||
|
expect(result.current.visible).toBe(false);
|
||||||
|
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
finishMarking?.();
|
||||||
|
await act(async () => {
|
||||||
|
await dismissal;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks once per anonymous account and can warn a later guest account", async () => {
|
||||||
|
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
rerender({ ...options, pathname: "/(app)/settings" });
|
||||||
|
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ ...options, userId: "guest-2" });
|
||||||
|
await waitFor(() => expect(hasSeenGuestAccountWarning).toHaveBeenCalledWith("guest-2"));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
describe("guestAccountWarningSeen", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
return AsyncStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a dismissal for that anonymous account", async () => {
|
||||||
|
await markGuestAccountWarningSeen("guest-1");
|
||||||
|
|
||||||
|
await expect(hasSeenGuestAccountWarning("guest-1")).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not suppress the recovery warning for a later guest account", async () => {
|
||||||
|
await markGuestAccountWarningSeen("guest-1");
|
||||||
|
|
||||||
|
await expect(hasSeenGuestAccountWarning("guest-2")).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../lib/guestAccountWarningSeen";
|
||||||
|
|
||||||
|
type GuestAccountWarningOptions = {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isAnonymous: boolean;
|
||||||
|
userId: string | null | undefined;
|
||||||
|
pathname: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the account recovery warning at most once for each anonymous account
|
||||||
|
* during a mounted app session. A dismissal is applied in memory before the
|
||||||
|
* durable write completes, so a slow storage operation cannot reopen it.
|
||||||
|
*/
|
||||||
|
export function useGuestAccountWarning({
|
||||||
|
isAuthenticated,
|
||||||
|
isAnonymous,
|
||||||
|
userId,
|
||||||
|
pathname,
|
||||||
|
}: GuestAccountWarningOptions) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const checkedUserIds = useRef(new Set<string>());
|
||||||
|
const dismissedUserIds = useRef(new Set<string>());
|
||||||
|
|
||||||
|
const isEligible = Boolean(
|
||||||
|
isAuthenticated
|
||||||
|
&& isAnonymous
|
||||||
|
&& userId
|
||||||
|
&& !pathname?.startsWith("/(auth)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEligible || !userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedUserIds.current.has(userId) || dismissedUserIds.current.has(userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedUserIds.current.add(userId);
|
||||||
|
let isCurrent = true;
|
||||||
|
|
||||||
|
void hasSeenGuestAccountWarning(userId).then((seen) => {
|
||||||
|
if (isCurrent && !seen && !dismissedUserIds.current.has(userId)) {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCurrent = false;
|
||||||
|
};
|
||||||
|
}, [isEligible, userId]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(async () => {
|
||||||
|
if (!userId) {
|
||||||
|
setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissedUserIds.current.add(userId);
|
||||||
|
setVisible(false);
|
||||||
|
await markGuestAccountWarningSeen(userId);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
return { visible: isEligible && visible, dismiss };
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
};
|
};
|
||||||
@@ -1,47 +1,31 @@
|
|||||||
import * as SecureStore from "expo-secure-store";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "guest_account_recovery_warning_seen";
|
const STORAGE_KEY_PREFIX = "guest_account_recovery_warning_seen";
|
||||||
|
|
||||||
async function getStorage(): Promise<boolean> {
|
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
|
||||||
|
|
||||||
|
async function getStorage(key: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
let data: string | null = null;
|
return (await AsyncStorage.getItem(key)) === "true";
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
|
||||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
|
||||||
data = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data = await SecureStore.getItemAsync(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data === "true";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading guest warning state:", error);
|
console.error("Error reading guest warning state:", error);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStorage(seen: boolean): Promise<void> {
|
async function setStorage(key: string, seen: boolean): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = seen ? "true" : "false";
|
await AsyncStorage.setItem(key, seen ? "true" : "false");
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
|
||||||
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await SecureStore.setItemAsync(STORAGE_KEY, data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving guest warning state:", error);
|
console.error("Error saving guest warning state:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasSeenGuestAccountWarning(): Promise<boolean> {
|
/** The recovery warning is shown at most once for each anonymous account. */
|
||||||
return getStorage();
|
export async function hasSeenGuestAccountWarning(userId: string): Promise<boolean> {
|
||||||
|
return getStorage(buildKey(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markGuestAccountWarningSeen(): Promise<void> {
|
export async function markGuestAccountWarningSeen(userId: string): Promise<void> {
|
||||||
await setStorage(true);
|
await setStorage(buildKey(userId), true);
|
||||||
}
|
}
|
||||||
@@ -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,76 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { CapturedNetworkRequest } from 'posthog-js'
|
||||||
|
import { redactInviteNetworkRequest, 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('maskCapturedNetworkRequestFn: redactInviteNetworkRequest')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => {
|
||||||
|
const inviteCode = 'A4K9BT2Q'
|
||||||
|
const request = {
|
||||||
|
name: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
entryType: 'resource',
|
||||||
|
startTime: 1,
|
||||||
|
duration: 2,
|
||||||
|
requestHeaders: {
|
||||||
|
referer: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
},
|
||||||
|
requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }),
|
||||||
|
responseHeaders: {
|
||||||
|
location: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
},
|
||||||
|
responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }),
|
||||||
|
} as CapturedNetworkRequest
|
||||||
|
|
||||||
|
const redacted = redactInviteNetworkRequest(request)
|
||||||
|
const serialized = JSON.stringify(redacted)
|
||||||
|
|
||||||
|
expect(redacted).not.toBe(request)
|
||||||
|
expect(redacted.requestHeaders).not.toBe(request.requestHeaders)
|
||||||
|
expect(redacted.responseHeaders).not.toBe(request.responseHeaders)
|
||||||
|
expect(serialized).not.toContain(inviteCode)
|
||||||
|
expect(serialized).toContain('/join/[invite]')
|
||||||
|
expect(JSON.stringify(request)).toContain(inviteCode)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+87
-14
@@ -1,20 +1,93 @@
|
|||||||
'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 { CapturedNetworkRequest, 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 redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
name: redactInvitePaths(request.name),
|
||||||
|
requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders,
|
||||||
|
requestBody: redactInvitePaths(request.requestBody),
|
||||||
|
responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders,
|
||||||
|
responseBody: redactInvitePaths(request.responseBody),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
session_recording: {
|
||||||
|
maskCapturedNetworkRequestFn: redactInviteNetworkRequest,
|
||||||
|
},
|
||||||
|
// 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" |
|
||||||
@@ -75,6 +75,36 @@ notification preview, and `dares.integration.test.ts` asserts no payload leaks i
|
|||||||
## History durability
|
## History durability
|
||||||
|
|
||||||
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
|
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
|
||||||
|
|
||||||
|
## Editorial catalogue seed
|
||||||
|
|
||||||
|
Migration `0023_dare_catalogue_150.sql` seeds ten deterministic-ID packs and 150
|
||||||
|
dares. It is replay-safe only when the existing rows exactly match the seed; a
|
||||||
|
deterministic-ID collision with changed content aborts the migration. The seed does
|
||||||
|
not change the dare schema, API, or mobile behaviour.
|
||||||
|
|
||||||
|
Only the 60 non-sexual rows in these packs are public and `allowed`:
|
||||||
|
|
||||||
|
- Little Things, Make Me Laugh, and Sweet & Romantic: 45 free dares.
|
||||||
|
- Surprise Me: 15 premium dares.
|
||||||
|
|
||||||
|
Flirty Messages (15 premium rows) is non-public and `unreviewed` until an
|
||||||
|
editorial line review records an allowed decision. Long-Distance Heat, Risky
|
||||||
|
Photos & Audio, Hands On, After Dark, and Power Play (75 premium rows) are
|
||||||
|
explicit, non-public, and `unreviewed`. They are private editorial inventory and
|
||||||
|
cannot be returned by the current fail-closed catalogue queries.
|
||||||
|
|
||||||
|
The 60 `allowed` rows carry a fixed migration review timestamp and a matching
|
||||||
|
`content_reviews` record. `changed_by` is null because a migration has no
|
||||||
|
authenticated human actor; the audit reason and timestamp identify this as the
|
||||||
|
system seed decision rather than attributing it to a fabricated profile. Exact
|
||||||
|
replay verifies this audit provenance too, so a divergent deterministic review ID
|
||||||
|
blocks the migration rather than being silently accepted.
|
||||||
|
|
||||||
|
The sender chooses a duration when sending a dare. Approved catalogue rows may
|
||||||
|
refer to "until the dare expires", which resolves to that sender-selected deadline.
|
||||||
|
The two private, unreviewed "within the next hour" rows remain unavailable until
|
||||||
|
their editorial review also confirms the sending experience communicates that limit.
|
||||||
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
|
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
|
||||||
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
|
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
|
||||||
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
|
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ historical notes. Agents should load only the section relevant to their task.
|
|||||||
- [Couple pairing](couple-pairing.md)
|
- [Couple pairing](couple-pairing.md)
|
||||||
- [Couple streaks](couple-streaks.md)
|
- [Couple streaks](couple-streaks.md)
|
||||||
- [Question selection](question-selection.md)
|
- [Question selection](question-selection.md)
|
||||||
|
- [Question interaction types](question-types.md)
|
||||||
- [Match notifications](match-notifications.md)
|
- [Match notifications](match-notifications.md)
|
||||||
- [Subscriptions](subscription-system.md)
|
- [Subscriptions](subscription-system.md)
|
||||||
- [Redemption codes](product/redemption.md)
|
- [Redemption codes](product/redemption.md)
|
||||||
@@ -35,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,283 @@
|
|||||||
|
# Play-safe spicy audio concept source
|
||||||
|
|
||||||
|
Draft source for private, up-to-60-second audio responses. This is editorial input only and is not a live catalogue import.
|
||||||
|
|
||||||
|
1. Theme: Flirty communication | Intensity: 2
|
||||||
|
Prompt: In a warm, playful tone, record the compliment from your partner that would make you smile all day.
|
||||||
|
Intended outcome: Your partner hears the exact kind of praise that lands for you and can use it naturally in a future message.
|
||||||
|
|
||||||
|
2. Theme: Flirty communication | Intensity: 3
|
||||||
|
Prompt: Record a voice note finishing this sentence: “When you look at me like that, I want you to know...”
|
||||||
|
Intended outcome: Your partner receives a personal signal of attraction and can reply with what they notice in that moment too.
|
||||||
|
|
||||||
|
3. Theme: Flirty communication | Intensity: 3
|
||||||
|
Prompt: Leave your partner a voice note with one bold thing you would love them to say quietly when you are alone together.
|
||||||
|
Intended outcome: Your partner learns a specific phrase or mood that could make a private moment feel more charged.
|
||||||
|
|
||||||
|
4. Theme: Flirty communication | Intensity: 4
|
||||||
|
Prompt: Record the invitation you would most like to hear when your partner wants to turn an ordinary evening into a close one.
|
||||||
|
Intended outcome: Your partner gets a clear, consent-forward way to initiate and can try it on a night that feels right.
|
||||||
|
|
||||||
|
5. Theme: Flirty communication | Intensity: 4
|
||||||
|
Prompt: In your most confident voice, tell your partner what makes you feel unmistakably wanted by them.
|
||||||
|
Intended outcome: Your partner hears the emotional cue behind your desire and can make future affection feel more intentional.
|
||||||
|
|
||||||
|
6. Theme: Anticipation | Intensity: 2
|
||||||
|
Prompt: Record a short countdown-style note about one small thing you are looking forward to doing together later.
|
||||||
|
Intended outcome: Your partner hears a concrete point of anticipation and can build on it with their own plan.
|
||||||
|
|
||||||
|
7. Theme: Anticipation | Intensity: 3
|
||||||
|
Prompt: Record the first message you would want from your partner before a deliberately flirty date night.
|
||||||
|
Intended outcome: Your partner learns how you like anticipation to begin and can send it before your next planned evening.
|
||||||
|
|
||||||
|
8. Theme: Anticipation | Intensity: 3
|
||||||
|
Prompt: Describe, in a low and unhurried voice, the mood you want waiting for you after a busy day apart.
|
||||||
|
Intended outcome: Your partner hears whether you want calm, playfulness, affection, or intensity and can help create that landing.
|
||||||
|
|
||||||
|
9. Theme: Anticipation | Intensity: 4
|
||||||
|
Prompt: Record a private tease that hints at the kind of attention you would love from your partner later, without explaining every detail.
|
||||||
|
Intended outcome: Your partner receives an intriguing cue they can ask about and turn into a jointly chosen plan.
|
||||||
|
|
||||||
|
10. Theme: Anticipation | Intensity: 4
|
||||||
|
Prompt: Leave a voice note naming the one thing your partner could do before you meet that would put you in a more daring mood.
|
||||||
|
Intended outcome: Your partner learns a practical lead-in, such as a message, outfit, setting, or affectionate gesture, that helps you open up.
|
||||||
|
|
||||||
|
11. Theme: Taking the lead | Intensity: 2
|
||||||
|
Prompt: Record a cheerful invitation for your partner to let you choose the next date-night detail.
|
||||||
|
Intended outcome: Your partner hears an easy handover of initiative and can say yes to one contained surprise.
|
||||||
|
|
||||||
|
12. Theme: Taking the lead | Intensity: 3
|
||||||
|
Prompt: In a decisive voice, tell your partner one private mood you would like to set for the two of you.
|
||||||
|
Intended outcome: Your partner learns the atmosphere you want to lead and can confirm whether it feels good for them too.
|
||||||
|
|
||||||
|
13. Theme: Taking the lead | Intensity: 3
|
||||||
|
Prompt: Record how you would ask your partner to trust you with planning a more adventurous evening together.
|
||||||
|
Intended outcome: Your partner hears a respectful version of your confidence and can agree on a boundary before the plan unfolds.
|
||||||
|
|
||||||
|
14. Theme: Taking the lead | Intensity: 4
|
||||||
|
Prompt: Leave a voice note telling your partner the first instruction you would enjoy giving in a mutually agreed private game.
|
||||||
|
Intended outcome: Your partner hears the style of leadership you enjoy and can opt into the game with clear limits.
|
||||||
|
|
||||||
|
15. Theme: Taking the lead | Intensity: 4
|
||||||
|
Prompt: Record the reassurance you would give your partner before you take charge of a bolder shared experience.
|
||||||
|
Intended outcome: Your partner hears how you would keep them comfortable and can name what would help them relax into your lead.
|
||||||
|
|
||||||
|
16. Theme: Following | Intensity: 2
|
||||||
|
Prompt: Record one thing your partner could choose for you that would feel like a thoughtful treat.
|
||||||
|
Intended outcome: Your partner learns a safe way to surprise you and can choose it for an upcoming evening.
|
||||||
|
|
||||||
|
17. Theme: Following | Intensity: 3
|
||||||
|
Prompt: In a soft voice, explain what helps you enjoy letting your partner set the pace.
|
||||||
|
Intended outcome: Your partner hears the conditions that make following feel exciting rather than uncertain.
|
||||||
|
|
||||||
|
18. Theme: Following | Intensity: 3
|
||||||
|
Prompt: Record the kind of check-in that makes you feel safe saying yes to your partner’s idea.
|
||||||
|
Intended outcome: Your partner learns the exact reassurance that supports your willingness and can use it before suggesting something new.
|
||||||
|
|
||||||
|
19. Theme: Following | Intensity: 4
|
||||||
|
Prompt: Leave a voice note describing one daring-but-private decision you would enjoy your partner making after checking in with you first.
|
||||||
|
Intended outcome: Your partner gets a consent-based example of initiative they could take and a cue to ask before acting.
|
||||||
|
|
||||||
|
20. Theme: Following | Intensity: 4
|
||||||
|
Prompt: Record what you would want your partner to say if they wanted you to surrender the planning for one intimate evening.
|
||||||
|
Intended outcome: Your partner hears language that makes a stronger invitation feel welcome, with room for you to decline or adjust.
|
||||||
|
|
||||||
|
21. Theme: Shared control | Intensity: 2
|
||||||
|
Prompt: Record a two-choice question your partner can answer to shape your next cosy evening together.
|
||||||
|
Intended outcome: Your partner hears two real options and can choose the version that feels most appealing.
|
||||||
|
|
||||||
|
22. Theme: Shared control | Intensity: 3
|
||||||
|
Prompt: Leave a voice note describing a private game where you and your partner would alternate making one decision each.
|
||||||
|
Intended outcome: Your partner learns a simple shared-control format and can suggest the first decision.
|
||||||
|
|
||||||
|
23. Theme: Shared control | Intensity: 3
|
||||||
|
Prompt: Record which part of a date night you would like your partner to lead and which part you would like to lead.
|
||||||
|
Intended outcome: Your partner hears a practical division of initiative and can co-create the evening rather than guess.
|
||||||
|
|
||||||
|
24. Theme: Shared control | Intensity: 4
|
||||||
|
Prompt: In an excited voice, explain how you would like to build a bolder shared moment one yes at a time.
|
||||||
|
Intended outcome: Your partner hears a stepwise consent rhythm and can add their own yes, no, or alternative at each point.
|
||||||
|
|
||||||
|
25. Theme: Shared control | Intensity: 4
|
||||||
|
Prompt: Record a private challenge that only works if both of you keep choosing the next step together.
|
||||||
|
Intended outcome: Your partner receives an invitation to collaborative play and can negotiate the limits before starting.
|
||||||
|
|
||||||
|
26. Theme: Boundaries and check-ins | Intensity: 2
|
||||||
|
Prompt: Record a kind sentence your partner can use to ask whether you are still enjoying a close moment.
|
||||||
|
Intended outcome: Your partner hears a check-in that feels caring rather than formal and can use it in real time.
|
||||||
|
|
||||||
|
27. Theme: Boundaries and check-ins | Intensity: 3
|
||||||
|
Prompt: In your own voice, explain the difference between a surprise that delights you and one that catches you off guard.
|
||||||
|
Intended outcome: Your partner learns your personal distinction and can plan novelty that feels welcome.
|
||||||
|
|
||||||
|
28. Theme: Boundaries and check-ins | Intensity: 3
|
||||||
|
Prompt: Record the easiest phrase for you to use when you want to slow down without ending the mood.
|
||||||
|
Intended outcome: Your partner hears your preferred pause signal and knows it protects connection rather than rejects them.
|
||||||
|
|
||||||
|
29. Theme: Boundaries and check-ins | Intensity: 4
|
||||||
|
Prompt: Leave a voice note telling your partner what they could ask before suggesting something considerably bolder than usual.
|
||||||
|
Intended outcome: Your partner gets a concrete consent opener and can ask it before raising a new idea.
|
||||||
|
|
||||||
|
30. Theme: Boundaries and check-ins | Intensity: 4
|
||||||
|
Prompt: Record what “I am curious, but not ready yet” sounds like in words that still feel affectionate to you.
|
||||||
|
Intended outcome: Your partner learns how to receive hesitation warmly and can leave the door open without pressure.
|
||||||
|
|
||||||
|
31. Theme: Aftercare and reconnection | Intensity: 2
|
||||||
|
Prompt: Record the small affectionate gesture that helps you feel close after a playful evening together.
|
||||||
|
Intended outcome: Your partner hears a specific reconnection preference they can offer without guessing.
|
||||||
|
|
||||||
|
32. Theme: Aftercare and reconnection | Intensity: 3
|
||||||
|
Prompt: In a calm voice, tell your partner what you most like hearing after you have both tried something new together.
|
||||||
|
Intended outcome: Your partner learns the reassurance or reflection that helps novelty become a positive shared memory.
|
||||||
|
|
||||||
|
33. Theme: Aftercare and reconnection | Intensity: 3
|
||||||
|
Prompt: Record how you would like your partner to check in with you the morning after an especially intimate night.
|
||||||
|
Intended outcome: Your partner hears the tone and timing that feel meaningful and can follow through the next day.
|
||||||
|
|
||||||
|
34. Theme: Aftercare and reconnection | Intensity: 4
|
||||||
|
Prompt: Leave a voice note about the honest question you would want your partner to ask after a more daring experience.
|
||||||
|
Intended outcome: Your partner learns how to invite useful feedback and can improve the next shared experience with you.
|
||||||
|
|
||||||
|
35. Theme: Aftercare and reconnection | Intensity: 4
|
||||||
|
Prompt: Record the words that would make you feel proud and cared for after you stepped outside your comfort zone together.
|
||||||
|
Intended outcome: Your partner hears how to celebrate your trust and can reinforce the kind of openness you want to grow.
|
||||||
|
|
||||||
|
36. Theme: Giving and receiving attention | Intensity: 2
|
||||||
|
Prompt: Record one affectionate way your partner could make you feel noticed when you are both at home.
|
||||||
|
Intended outcome: Your partner gets a simple attention cue they can use in an ordinary evening.
|
||||||
|
|
||||||
|
37. Theme: Giving and receiving attention | Intensity: 3
|
||||||
|
Prompt: Leave a voice note describing how you know the difference between rushed affection and attention that feels fully present.
|
||||||
|
Intended outcome: Your partner learns what presence sounds and feels like to you and can adjust their pace.
|
||||||
|
|
||||||
|
38. Theme: Giving and receiving attention | Intensity: 3
|
||||||
|
Prompt: Record the kind of praise you would most enjoy giving your partner when they make you feel desired.
|
||||||
|
Intended outcome: Your partner hears the feedback that encourages them and can understand what is working for you.
|
||||||
|
|
||||||
|
39. Theme: Giving and receiving attention | Intensity: 4
|
||||||
|
Prompt: In a warm, deliberate voice, tell your partner where you would like their focus to be during a private moment.
|
||||||
|
Intended outcome: Your partner hears the emotional or sensory focus you want and can ask a follow-up before acting on it.
|
||||||
|
|
||||||
|
40. Theme: Giving and receiving attention | Intensity: 4
|
||||||
|
Prompt: Record the one thing your partner could do to make you feel they have set aside the whole evening just for you.
|
||||||
|
Intended outcome: Your partner learns a concrete sign of devoted attention and can weave it into a future plan.
|
||||||
|
|
||||||
|
41. Theme: Sensory cues | Intensity: 2
|
||||||
|
Prompt: Record the sound, song, or kind of silence that makes an evening feel more intimate to you.
|
||||||
|
Intended outcome: Your partner learns an audible atmosphere cue and can bring it into your next shared setting.
|
||||||
|
|
||||||
|
42. Theme: Sensory cues | Intensity: 3
|
||||||
|
Prompt: In a slow voice, describe the lighting that makes you feel most relaxed and attractive.
|
||||||
|
Intended outcome: Your partner hears a practical setting preference and can prepare the room with your comfort in mind.
|
||||||
|
|
||||||
|
43. Theme: Sensory cues | Intensity: 3
|
||||||
|
Prompt: Record the scent or familiar smell that you associate with feeling especially close to your partner.
|
||||||
|
Intended outcome: Your partner learns a personal sensory association and can use it to create a recognisable shared mood.
|
||||||
|
|
||||||
|
44. Theme: Sensory cues | Intensity: 4
|
||||||
|
Prompt: Leave a voice note about the kind of music that would make you feel more confident trying a bolder private mood together.
|
||||||
|
Intended outcome: Your partner receives a specific confidence cue and can make a playlist before you explore the mood.
|
||||||
|
|
||||||
|
45. Theme: Private performance | Intensity: 2
|
||||||
|
Prompt: Record a playful introduction as though you are welcoming your partner to a date night you designed just for them.
|
||||||
|
Intended outcome: Your partner hears your enthusiasm and can respond with the detail that would make the invitation even better.
|
||||||
|
|
||||||
|
46. Theme: Private performance | Intensity: 3
|
||||||
|
Prompt: In character, record a short mystery message that gives your partner one clue about a private surprise.
|
||||||
|
Intended outcome: Your partner gets a voice-led tease and can guess, ask for another clue, or agree to be surprised.
|
||||||
|
|
||||||
|
47. Theme: Private performance | Intensity: 3
|
||||||
|
Prompt: Record a dramatic trailer voice-over for the next adventurous evening you want to create together.
|
||||||
|
Intended outcome: Your partner hears the mood as performance, making it easier to discuss the plan without pressure.
|
||||||
|
|
||||||
|
48. Theme: Private performance | Intensity: 4
|
||||||
|
Prompt: Leave a voice note acting out the confident version of yourself you would like to bring into a private date night.
|
||||||
|
Intended outcome: Your partner hears a new side of your energy and can encourage that version of you when you are together.
|
||||||
|
|
||||||
|
49. Theme: Clothes and confidence | Intensity: 2
|
||||||
|
Prompt: Record how you feel when you wear the outfit that makes you walk a little taller.
|
||||||
|
Intended outcome: Your partner learns the confidence signal behind a favourite look and can compliment it more meaningfully.
|
||||||
|
|
||||||
|
50. Theme: Clothes and confidence | Intensity: 3
|
||||||
|
Prompt: Leave a voice note telling your partner what kind of look from them catches your attention first.
|
||||||
|
Intended outcome: Your partner hears a specific style cue and can choose an outfit that makes them feel noticed.
|
||||||
|
|
||||||
|
51. Theme: Clothes and confidence | Intensity: 3
|
||||||
|
Prompt: In an encouraging voice, tell your partner how you would like them to make you feel confident before a private evening.
|
||||||
|
Intended outcome: Your partner learns whether words, time to get ready, a compliment, or a small gesture helps you feel at ease.
|
||||||
|
|
||||||
|
52. Theme: Clothes and confidence | Intensity: 4
|
||||||
|
Prompt: Record the outfit-related surprise you would enjoy receiving from your partner after they check it feels right for you.
|
||||||
|
Intended outcome: Your partner gets a consent-aware idea for a bolder visual surprise and can confirm your comfort before planning it.
|
||||||
|
|
||||||
|
53. Theme: Fantasy exchange | Intensity: 2
|
||||||
|
Prompt: Record a fictional date-night scene you would enjoy stepping into with your partner, keeping it all about atmosphere.
|
||||||
|
Intended outcome: Your partner hears a low-pressure shared fantasy and can suggest a small real-world version of its mood.
|
||||||
|
|
||||||
|
54. Theme: Fantasy exchange | Intensity: 3
|
||||||
|
Prompt: Leave a voice note beginning, “In our imaginary perfect evening, you would surprise me by...”
|
||||||
|
Intended outcome: Your partner receives a playful desire in fictional form and can ask which part you would actually enjoy trying.
|
||||||
|
|
||||||
|
55. Theme: Fantasy exchange | Intensity: 3
|
||||||
|
Prompt: Record the made-up place where you and your partner would feel free to be more playful and uninhibited together.
|
||||||
|
Intended outcome: Your partner hears the setting behind your fantasy and can recreate its feeling privately and safely.
|
||||||
|
|
||||||
|
56. Theme: Fantasy exchange | Intensity: 4
|
||||||
|
Prompt: In a conspiratorial voice, tell your partner the private rule that would make your imaginary evening feel thrilling.
|
||||||
|
Intended outcome: Your partner hears one negotiable game rule and can decide with you whether it belongs in real life.
|
||||||
|
|
||||||
|
57. Theme: Roleplay | Intensity: 2
|
||||||
|
Prompt: Record a playful opening line for a pretend first meeting with your partner.
|
||||||
|
Intended outcome: Your partner gets an easy roleplay entry point and can answer in character if they want to continue.
|
||||||
|
|
||||||
|
58. Theme: Roleplay | Intensity: 3
|
||||||
|
Prompt: Leave a voice note as a fictional version of yourself inviting your partner on a secret date.
|
||||||
|
Intended outcome: Your partner hears the role and tone that appeals to you and can choose whether to join the scene.
|
||||||
|
|
||||||
|
59. Theme: Roleplay | Intensity: 3
|
||||||
|
Prompt: Record the character trait you would enjoy your partner pretending to have for one private evening.
|
||||||
|
Intended outcome: Your partner learns the energy you find exciting, such as confidence, mystery, gentleness, or playfulness.
|
||||||
|
|
||||||
|
60. Theme: Roleplay | Intensity: 4
|
||||||
|
Prompt: In character, record the agreement you would want before a bolder roleplay scene begins.
|
||||||
|
Intended outcome: Your partner hears your preferred boundaries, stop signal, and tone before either of you enters the scene.
|
||||||
|
|
||||||
|
61. Theme: Settings and atmosphere | Intensity: 2
|
||||||
|
Prompt: Record a description of the room that makes you instantly want to put your phones away and focus on each other.
|
||||||
|
Intended outcome: Your partner learns the environmental details that help you reconnect and can help create them.
|
||||||
|
|
||||||
|
62. Theme: Settings and atmosphere | Intensity: 3
|
||||||
|
Prompt: Leave a voice note about the time of day when you are most open to a deliberately intimate evening.
|
||||||
|
Intended outcome: Your partner hears your natural rhythm and can plan closeness for a time when you are more available.
|
||||||
|
|
||||||
|
63. Theme: Settings and atmosphere | Intensity: 3
|
||||||
|
Prompt: Record the first three details you would set up to make a home evening feel like an occasion.
|
||||||
|
Intended outcome: Your partner receives an actionable atmosphere recipe and can take ownership of one detail.
|
||||||
|
|
||||||
|
64. Theme: Settings and atmosphere | Intensity: 4
|
||||||
|
Prompt: In a hushed voice, describe the kind of private escape you would like to create together without leaving home.
|
||||||
|
Intended outcome: Your partner hears the emotional destination you want and can help transform the space into it.
|
||||||
|
|
||||||
|
65. Theme: Surprise and novelty | Intensity: 2
|
||||||
|
Prompt: Record one harmless surprise your partner could prepare that would make you feel pursued.
|
||||||
|
Intended outcome: Your partner learns an approachable surprise idea and can choose a moment to use it.
|
||||||
|
|
||||||
|
66. Theme: Surprise and novelty | Intensity: 3
|
||||||
|
Prompt: Leave a voice note explaining what makes a surprise feel exciting to you instead of overwhelming.
|
||||||
|
Intended outcome: Your partner hears the ingredients of welcome novelty and can avoid the parts that undermine it.
|
||||||
|
|
||||||
|
67. Theme: Surprise and novelty | Intensity: 3
|
||||||
|
Prompt: Record a voice-only clue about a new private activity you would be curious to try together.
|
||||||
|
Intended outcome: Your partner gets an invitation to ask questions and decide whether the activity suits both of you.
|
||||||
|
|
||||||
|
68. Theme: Surprise and novelty | Intensity: 4
|
||||||
|
Prompt: In a playful whisper, tell your partner the one advance question they should ask before planning a bolder surprise.
|
||||||
|
Intended outcome: Your partner learns the consent question that keeps novelty safe and can use it before making plans.
|
||||||
|
|
||||||
|
69. Theme: Long-distance desire | Intensity: 2
|
||||||
|
Prompt: Record the everyday sound from your life that you wish your partner could hear when you are apart.
|
||||||
|
Intended outcome: Your partner gets a vivid piece of your day and can send one back to make distance feel smaller.
|
||||||
|
|
||||||
|
70. Theme: Long-distance desire | Intensity: 4
|
||||||
|
Prompt: Leave a private voice note describing how you would like your reunion to begin when you next have uninterrupted time together.
|
||||||
|
Intended outcome: Your partner hears a clear reunion hope and can respond with their own version before you meet.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Concept Set A: Swipe proposals
|
||||||
|
|
||||||
|
Draft catalogue for the existing swipe interaction. A match represents both partners opting into the stated outcome. `partner_text` is the wording delivered to the other partner; `null` denotes a symmetric proposition.
|
||||||
|
|
||||||
|
## Asymmetric swipe concepts
|
||||||
|
|
||||||
|
| # | Intensity | Text | Partner text | Matched outcome |
|
||||||
|
|---:|---:|---|---|---|
|
||||||
|
| 1 | 2 | Send your partner a message describing the kind of attention you would love later. | Receive a message from your partner describing the kind of attention they would love later. | One partner has invited the other to create a specific kind of private attention. |
|
||||||
|
| 2 | 2 | Ask your partner to choose one compliment you should hear in a more intimate setting. | Choose one compliment your partner should hear in a more intimate setting. | One partner wants to give a targeted confidence-building compliment and the other wants to receive it. |
|
||||||
|
| 3 | 2 | Tell your partner one private thought that makes you feel closer to them. | Hear one private thought from your partner that makes them feel closer to you. | One partner is ready to share a personal desire for connection and the other is open to receiving it. |
|
||||||
|
| 4 | 2 | Ask your partner to give you a private instruction in a message. | Give your partner a private instruction in a message. | One partner wants to follow a playful instruction and the other wants to set it. |
|
||||||
|
| 5 | 3 | Describe a private scenario you would like your partner to set up for you. | Set up a private scenario your partner describes wanting. | One partner wants to describe a desired experience and the other wants to bring it to life. |
|
||||||
|
| 6 | 3 | Ask your partner to tell you what they find hardest to resist about you. | Tell your partner what you find hardest to resist about them. | One partner wants a direct expression of desire and the other wants to give it. |
|
||||||
|
| 7 | 3 | Invite your partner to send you a voice note that builds anticipation. | Send your partner a voice note that builds anticipation. | One partner wants to be teased by voice and the other wants to create that anticipation. |
|
||||||
|
| 8 | 4 | Ask your partner to tell you one private wish they want you to consider. | Tell your partner one private wish you want them to consider. | One partner is open to receiving a direct private wish and the other is ready to name one. |
|
||||||
|
| 9 | 2 | Let your partner choose a small signal that means they want your attention. | Choose a small signal that tells your partner you want their attention. | One partner wants to recognise a private invitation and the other wants to create one. |
|
||||||
|
| 10 | 2 | Ask your partner to plan the first few minutes of a private date. | Plan the first few minutes of a private date for your partner. | One partner wants to arrive without knowing the opening and the other wants to create it. |
|
||||||
|
| 11 | 3 | Let your partner decide when you are allowed to open a private surprise. | Decide when your partner is allowed to open a private surprise. | One partner wants delayed anticipation and the other wants to control the reveal. |
|
||||||
|
| 12 | 3 | Wait for your partner to give you the cue to begin a private plan. | Give your partner the cue to begin a private plan. | One partner wants to wait for permission and the other wants to choose the moment. |
|
||||||
|
| 13 | 3 | Ask your partner to create a private clue trail leading to what they want. | Create a private clue trail leading to what you want from your partner. | One partner wants to follow an intimate trail of hints and the other wants to design it. |
|
||||||
|
| 14 | 3 | Let your partner choose one thing to postpone to make the build-up longer. | Choose one thing to postpone to make the build-up longer for your partner. | One partner wants a slower build-up and the other wants to decide what is held back. |
|
||||||
|
| 15 | 4 | Ask your partner to plan a private surprise within limits you agree first. | Plan a private surprise for your partner within limits you agree first. | One partner wants a contained surprise and the other wants to lead it safely. |
|
||||||
|
| 16 | 4 | Let your partner choose the order of a private experience without telling you. | Choose the order of a private experience without telling your partner. | One partner wants uncertainty within an agreed experience and the other wants to choreograph it. |
|
||||||
|
| 17 | 2 | Choose what your partner wears for a private date. | Let your partner choose what you wear for a private date. | One partner wants to style the moment and the other wants to be chosen for. |
|
||||||
|
| 18 | 2 | Choose the music that sets the mood for your private time together. | Let your partner choose the music that sets the mood for your private time together. | One partner wants to set the atmosphere and the other wants to enter it. |
|
||||||
|
| 19 | 3 | Give your partner three private options and choose which one they follow. | Let your partner give you three private options and choose which one you follow. | One partner wants to offer controlled choices and the other wants to accept a selected direction. |
|
||||||
|
| 20 | 3 | Plan a private experience where your partner follows your pace. | Follow your partner’s pace during a private experience they plan. | One partner wants to guide the rhythm and the other wants to be guided. |
|
||||||
|
| 21 | 3 | Choose the first form of affection you give your partner in private. | Let your partner choose the first form of affection they give you in private. | One partner wants to initiate deliberately and the other wants to receive an intentional opening. |
|
||||||
|
| 22 | 4 | Decide the private role you want your partner to play for you. | Play the private role your partner chooses for you. | One partner wants to direct a role-based encounter and the other wants to inhabit the chosen role. |
|
||||||
|
| 23 | 4 | Set the rules for a private game your partner agrees to play. | Play a private game using rules your partner sets. | One partner wants to set a consensual frame and the other wants to play within it. |
|
||||||
|
| 24 | 4 | Choose when your partner may take the next step in a private encounter. | Let your partner choose when you may take the next step in a private encounter. | One partner wants to control pacing and the other wants to receive a clear cue. |
|
||||||
|
| 25 | 2 | Let your partner choose the private setting where you spend time together. | Choose the private setting where you spend time with your partner. | One partner wants to be led into a setting and the other wants to choose it. |
|
||||||
|
| 26 | 2 | Follow a private date plan your partner creates without seeing it first. | Create a private date plan for your partner without showing it first. | One partner wants a planned surprise and the other wants to arrange it. |
|
||||||
|
| 27 | 3 | Let your partner decide how slowly you take a private moment together. | Decide how slowly you take a private moment together with your partner. | One partner wants to surrender the pace and the other wants to set it. |
|
||||||
|
| 28 | 3 | Follow one private instruction from your partner without negotiating the details first. | Give your partner one private instruction without negotiating the details first. | One partner wants to follow a simple direction and the other wants to give one. |
|
||||||
|
| 29 | 3 | Let your partner choose the kind of attention you receive first. | Choose the kind of attention your partner receives first. | One partner wants to receive a chosen form of attention and the other wants to give it. |
|
||||||
|
| 30 | 4 | Let your partner decide the sequence of an agreed private experience. | Decide the sequence of an agreed private experience for your partner. | One partner wants to follow an agreed sequence and the other wants to lead it. |
|
||||||
|
| 31 | 4 | Follow a private role your partner sets after discussing your limits. | Set a private role for your partner after discussing their limits. | One partner wants to follow a defined role and the other wants to set it within agreed limits. |
|
||||||
|
| 32 | 2 | Choose one element of a private date while your partner chooses the other. | Choose one element of a private date while your partner chooses the other. | Both partners want to co-create a private date with equal input. |
|
||||||
|
| 33 | 2 | Take turns choosing how you show affection during a private evening. | Take turns choosing how you show affection during a private evening. | Both partners want alternating opportunities to initiate affection. |
|
||||||
|
| 34 | 3 | Create a private list of three ideas and let your partner pick one for you. | Create a private list of three ideas and let your partner pick one for you. | Both partners want to offer choices and select an experience for each other. |
|
||||||
|
| 35 | 3 | Agree that each of you gets one turn to direct part of a private experience. | Agree that each of you gets one turn to direct part of a private experience. | Both partners want shared leadership with clear turns. |
|
||||||
|
| 36 | 3 | Choose a private challenge for your partner and accept one from them. | Choose a private challenge for your partner and accept one from them. | Both partners want to exchange playful, consensual challenges. |
|
||||||
|
| 37 | 4 | Take turns naming one thing you want and one thing you are willing to give. | Take turns naming one thing you want and one thing you are willing to give. | Both partners want to negotiate desire through reciprocal offers. |
|
||||||
|
| 38 | 4 | Agree that either partner can change the direction of a private experience once. | Agree that either partner can change the direction of a private experience once. | Both partners want shared control and permission to redirect the experience. |
|
||||||
|
| 39 | 2 | Ask your partner to name the kind of reassurance they want before trying something new. | Tell your partner the kind of reassurance you want before trying something new. | One partner wants to offer reassurance and the other wants to name what helps. |
|
||||||
|
| 40 | 2 | Choose a private check-in phrase your partner can use with you. | Use a private check-in phrase your partner chooses for you. | One partner wants to establish a check-in and the other wants to use it. |
|
||||||
|
| 41 | 3 | Tell your partner one boundary that helps you relax during private exploration. | Hear one boundary from your partner that helps them relax during private exploration. | One partner wants to state a boundary and the other is open to receiving it. |
|
||||||
|
| 42 | 3 | Ask your partner to choose a simple signal for slowing down. | Choose a simple signal for slowing down with your partner. | One partner wants a clear slowing-down signal and the other wants to define it. |
|
||||||
|
| 43 | 3 | Invite your partner to tell you what would make a new idea feel safer. | Tell your partner what would make a new idea feel safer for you. | One partner wants to adapt to the other’s needs and the other wants to express them. |
|
||||||
|
| 44 | 4 | Ask your partner to set one firm limit before you lead a private experience. | Set one firm limit before your partner leads a private experience. | One partner wants to lead only with a clear limit and the other wants to set it. |
|
||||||
|
| 45 | 4 | Ask your partner to tell you what would make them stop a private experience. | Tell your partner what would make you stop a private experience. | One partner wants clarity about stopping conditions and the other wants to define them. |
|
||||||
|
| 46 | 2 | Ask your partner to choose how they would like to reconnect after private time. | Choose how you would like to reconnect with your partner after private time. | One partner wants to provide the preferred form of reconnection and the other wants to choose it. |
|
||||||
|
| 47 | 2 | Tell your partner one thing they could say that would make you feel especially appreciated afterwards. | Hear one thing you could say that would make your partner feel especially appreciated afterwards. | One partner wants to name an affirming phrase and the other wants to offer it. |
|
||||||
|
| 48 | 3 | Let your partner choose whether you stay close, talk, or have quiet space after an intimate moment. | Choose whether you stay close, talk, or have quiet space after an intimate moment with your partner. | One partner wants the other to decide the reconnection style and the other wants to choose it. |
|
||||||
|
| 49 | 3 | Ask your partner to tell you what kind of comfort helps them feel grounded afterwards. | Tell your partner what kind of comfort helps you feel grounded afterwards. | One partner wants to provide grounding comfort and the other wants to describe it. |
|
||||||
|
| 50 | 3 | Give your partner a chance to tell you what they liked most after private exploration. | Tell your partner what you liked most after private exploration. | One partner wants feedback and the other wants to share positive detail. |
|
||||||
|
| 51 | 4 | Ask your partner to choose one way you can make them feel cared for after an intense private experience. | Choose one way your partner can make you feel cared for after an intense private experience. | One partner wants to provide deliberate care and the other wants to specify it. |
|
||||||
|
| 52 | 4 | Tell your partner what you need from them if a private experience leaves you feeling exposed. | Hear what your partner needs if a private experience leaves them feeling exposed. | One partner wants to name a vulnerability need and the other wants to meet it. |
|
||||||
|
| 53 | 2 | Let your partner choose one part of you to compliment slowly and sincerely. | Choose one part of your partner to compliment slowly and sincerely. | One partner wants to receive focused admiration and the other wants to give it. |
|
||||||
|
| 54 | 2 | Ask your partner to give you their full attention while you share what feels romantic to you. | Give your partner your full attention while they share what feels romantic to them. | One partner wants to be heard about romance and the other wants to listen attentively. |
|
||||||
|
| 55 | 3 | Receive a slow, uninterrupted moment of affection chosen by your partner. | Give your partner a slow, uninterrupted moment of affection you choose. | One partner wants to receive focused affection and the other wants to choose how to give it. |
|
||||||
|
| 56 | 3 | Give your partner the kind of private attention they ask for without asking for anything back first. | Receive the kind of private attention you ask for without giving anything back first. | One partner wants to give without immediate reciprocity and the other wants to receive freely. |
|
||||||
|
| 57 | 3 | Let your partner choose how they want you to focus on them during private time. | Choose how you want your partner to focus on you during private time. | One partner wants to direct the attention they receive and the other wants to follow that direction. |
|
||||||
|
| 58 | 4 | Ask your partner to give you their full focus while you set the pace. | Give your partner your full focus while they set the pace. | One partner wants to lead the pace while receiving undivided attention from the other. |
|
||||||
|
| 59 | 4 | Let your partner ask for one kind of private attention you will focus on giving. | Ask your partner for one kind of private attention they will focus on giving. | One partner wants to fulfil a focused request and the other wants to make one. |
|
||||||
|
| 60 | 4 | Choose how you want your partner to make you feel desired in private. | Make your partner feel desired in private in the way they choose. | One partner wants to name a desired experience and the other wants to deliver it. |
|
||||||
|
|
||||||
|
## Symmetric swipe proposals
|
||||||
|
|
||||||
|
| # | Intensity | Text | Partner text | Matched outcome |
|
||||||
|
|---:|---:|---|---|---|
|
||||||
|
| 1 | 2 | Exchange a private message naming one thing you find irresistible about each other. | null | Both partners agree to trade direct expressions of attraction. |
|
||||||
|
| 2 | 2 | Give each other one compliment that feels more personal than usual. | null | Both partners want a more intimate exchange of appreciation. |
|
||||||
|
| 3 | 3 | Send each other a voice note that sets a private mood. | null | Both partners agree to create anticipation through voice. |
|
||||||
|
| 4 | 4 | Share one private wish each and discuss what would make it feel exciting and safe. | null | Both partners agree to reveal a private wish and discuss its boundaries. |
|
||||||
|
| 5 | 2 | Plan a private surprise for each other using only a few clues. | null | Both partners agree to build anticipation through limited information. |
|
||||||
|
| 6 | 3 | Agree on a private signal that starts an anticipated experience. | null | Both partners want a mutual cue that begins their planned moment. |
|
||||||
|
| 7 | 3 | Leave each other one private hint about something you want to try together. | null | Both partners agree to exchange hints before discussing an idea. |
|
||||||
|
| 8 | 4 | Plan a private experience where neither of you knows the full sequence beforehand. | null | Both partners want surprise while retaining an agreed framework. |
|
||||||
|
| 9 | 2 | Take turns planning a private date where the other person only knows the starting point. | null | Both partners agree to alternate between leading and being surprised. |
|
||||||
|
| 10 | 3 | Take turns giving one private instruction and following one. | null | Both partners want to alternate consensual direction and follow-through. |
|
||||||
|
| 11 | 3 | Choose a private role for each other and agree whether to swap halfway through. | null | Both partners want a role-based experience with an optional switch. |
|
||||||
|
| 12 | 4 | Take turns setting the pace during an agreed private experience. | null | Both partners agree to hand leadership back and forth. |
|
||||||
|
| 13 | 2 | Each choose one part of a private date and combine the plans. | null | Both partners want to build a shared private date. |
|
||||||
|
| 14 | 3 | Make a private list of three ideas each and choose one from the other person’s list. | null | Both partners agree to select a mutually desired new idea. |
|
||||||
|
| 15 | 3 | Use a coin toss to decide who leads the first part of your private time together. | null | Both partners want a playful, equal way to assign the opening lead. |
|
||||||
|
| 16 | 4 | Agree that either person can pause, redirect, or end a private experience without explanation. | null | Both partners establish equal control over continuation and change. |
|
||||||
|
| 17 | 2 | Choose a simple phrase that means you want more reassurance. | null | Both partners agree on a shared reassurance signal. |
|
||||||
|
| 18 | 3 | Name one thing that helps you feel secure when trying something unfamiliar. | null | Both partners agree to share and honour their comfort needs. |
|
||||||
|
| 19 | 3 | Agree on a signal that means slow down and a signal that means stop. | null | Both partners establish clear ways to adjust or end an experience. |
|
||||||
|
| 20 | 4 | Discuss one private boundary you want honoured before exploring a more intense idea. | null | Both partners agree to state a firm limit before escalating. |
|
||||||
|
| 21 | 2 | Tell each other how you most like to reconnect after a private date. | null | Both partners agree to share their preferred reconnection style. |
|
||||||
|
| 22 | 3 | Set aside time to tell each other what felt especially good about a private experience. | null | Both partners agree to exchange affirming feedback. |
|
||||||
|
| 23 | 3 | Choose a shared ritual that helps you feel close after intimate time. | null | Both partners want a repeatable ritual of reconnection. |
|
||||||
|
| 24 | 4 | Agree to check in with each other after trying something that feels emotionally intense. | null | Both partners commit to care and reflection after deeper exploration. |
|
||||||
|
| 25 | 2 | Take turns receiving five uninterrupted minutes of affectionate attention. | null | Both partners agree to give and receive undivided affection. |
|
||||||
|
| 26 | 3 | Take turns asking for the kind of private attention you most want. | null | Both partners agree to voice a specific desire and respond to it. |
|
||||||
|
| 27 | 3 | Give each other a turn where one person only receives and the other only gives attention. | null | Both partners want reciprocal focused giving and receiving. |
|
||||||
|
| 28 | 4 | Tell each other one way you want to feel desired, then make space for both requests. | null | Both partners agree to make each other feel desired in individually meaningful ways. |
|
||||||
|
| 29 | 4 | Take turns creating a private experience centred entirely on the other person’s enjoyment. | null | Both partners want to alternate between giving undivided attention and receiving it. |
|
||||||
|
| 30 | 4 | Agree to ask directly for what you want and answer honestly about what you can give. | null | Both partners agree to a direct, consent-led exchange of desire. |
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# Concept Set B
|
||||||
|
|
||||||
|
Status: authored source fixture. Source-only and unpublished.
|
||||||
|
|
||||||
|
This document contains 60 asymmetric concepts and 30 symmetric concepts for the
|
||||||
|
play-safe spicy catalogue repair. Each entry has a theme, intensity, primary
|
||||||
|
copy, exact partner copy, and an intended outcome.
|
||||||
|
|
||||||
|
## Asymmetric concepts
|
||||||
|
|
||||||
|
### Sensory cues
|
||||||
|
|
||||||
|
#### A01
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you be up for choosing a scent that you want your partner to wear for a close evening?
|
||||||
|
Partner text: Would you be up for choosing a scent that you want your partner to wear for a close evening?
|
||||||
|
Intended outcome: One partner selects a scent cue for the other to use on their next intentional date night.
|
||||||
|
|
||||||
|
#### A02
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to prepare a playlist that sets the mood for a more intimate evening with you?
|
||||||
|
Partner text: Would you like to prepare a playlist that sets the mood for a more intimate evening with your partner?
|
||||||
|
Intended outcome: The receiving partner gets a personally chosen mood playlist.
|
||||||
|
|
||||||
|
#### A03
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner guiding you through a slow eyes-closed guessing game with safe household textures?
|
||||||
|
Partner text: Would you enjoy guiding your partner through a slow eyes-closed guessing game with safe household textures?
|
||||||
|
Intended outcome: The proposer leads a private sensory game and the receiver agrees to participate.
|
||||||
|
|
||||||
|
#### A04
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to choose the lighting for a close evening together?
|
||||||
|
Partner text: Would you like to choose the lighting for a close evening together with your partner?
|
||||||
|
Intended outcome: One partner creates the visual atmosphere while the other agrees to experience it.
|
||||||
|
|
||||||
|
#### A05
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner asking you to focus only on their voice for a few uninterrupted minutes?
|
||||||
|
Partner text: Would you be open to asking your partner to focus only on your voice for a few uninterrupted minutes?
|
||||||
|
Intended outcome: The proposer receives undivided, voice-led attention in private.
|
||||||
|
|
||||||
|
#### A06
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to pick a dessert or drink for you to share slowly after your date?
|
||||||
|
Partner text: Would you like to pick a dessert or drink for your partner to share slowly after your date?
|
||||||
|
Intended outcome: The proposer chooses a shared treat that extends a date into a close moment.
|
||||||
|
|
||||||
|
#### A07
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you enjoy your partner choosing one song and asking you to stay close with them until it ends?
|
||||||
|
Partner text: Would you enjoy choosing one song and asking your partner to stay close with you until it ends?
|
||||||
|
Intended outcome: The couple agrees to a deliberate, uninterrupted closeness ritual around one song.
|
||||||
|
|
||||||
|
#### A08
|
||||||
|
Theme: Sensory cues
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to leave a note telling you one small detail they find especially attractive about you?
|
||||||
|
Partner text: Would you like to leave your partner a note telling them one small detail you find especially attractive about them?
|
||||||
|
Intended outcome: The proposer gives the receiver a specific, private compliment in writing.
|
||||||
|
|
||||||
|
### Private performance
|
||||||
|
|
||||||
|
#### A09
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to choose a song and perform a playful lip-sync just for you?
|
||||||
|
Partner text: Would you like to choose a song and perform a playful lip-sync just for your partner?
|
||||||
|
Intended outcome: One partner gives the other a private, playful performance.
|
||||||
|
|
||||||
|
#### A10
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner giving you a short, confident compliment speech before your next date night?
|
||||||
|
Partner text: Would you enjoy giving your partner a short, confident compliment speech before your next date night?
|
||||||
|
Intended outcome: The receiver gets a deliberate verbal build-up before the date.
|
||||||
|
|
||||||
|
#### A11
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you be up for your partner choosing a song for you to slow-dance to together at home?
|
||||||
|
Partner text: Would you be up for choosing a song for you and your partner to slow-dance to together at home?
|
||||||
|
Intended outcome: The proposer picks the soundtrack for a private slow dance.
|
||||||
|
|
||||||
|
#### A12
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to tell you, face to face, the version of you they find hardest to resist?
|
||||||
|
Partner text: Would you like to tell your partner, face to face, the version of them you find hardest to resist?
|
||||||
|
Intended outcome: The couple shares a direct, personal desire statement.
|
||||||
|
|
||||||
|
#### A13
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you enjoy your partner making you laugh with a deliberately over-the-top romantic introduction?
|
||||||
|
Partner text: Would you enjoy making your partner laugh with a deliberately over-the-top romantic introduction?
|
||||||
|
Intended outcome: One partner performs a comic, affectionate introduction for the other.
|
||||||
|
|
||||||
|
#### A14
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to write and read you a short invitation for a private evening together?
|
||||||
|
Partner text: Would you like to write and read your partner a short invitation for a private evening together?
|
||||||
|
Intended outcome: The proposer creates a spoken invitation that frames the evening.
|
||||||
|
|
||||||
|
#### A15
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner asking you to sit back while they set the pace for the first part of your evening?
|
||||||
|
Partner text: Would you be open to asking your partner to sit back while you set the pace for the first part of your evening?
|
||||||
|
Intended outcome: The proposer leads the opening part of a mutually agreed private evening.
|
||||||
|
|
||||||
|
#### A16
|
||||||
|
Theme: Private performance
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to reveal one new side of their confidence through a private dance for you?
|
||||||
|
Partner text: Would you like to reveal one new side of your confidence through a private dance for your partner?
|
||||||
|
Intended outcome: One partner shares a confidence-focused private dance.
|
||||||
|
|
||||||
|
### Clothes and confidence
|
||||||
|
|
||||||
|
#### A17
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to choose one outfit detail for your next date that makes them smile?
|
||||||
|
Partner text: Would you like to choose one outfit detail for your partner's next date that makes you smile?
|
||||||
|
Intended outcome: The proposer chooses a small style detail for the receiver to wear.
|
||||||
|
|
||||||
|
#### A18
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner putting together a look for you that feels more daring than your usual style?
|
||||||
|
Partner text: Would you enjoy putting together a look for your partner that feels more daring than their usual style?
|
||||||
|
Intended outcome: One partner styles the other for a bolder but comfortable date-night look.
|
||||||
|
|
||||||
|
#### A19
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to ask you to wear the colour they most associate with you feeling confident?
|
||||||
|
Partner text: Would you like to ask your partner to wear the colour you most associate with them feeling confident?
|
||||||
|
Intended outcome: The receiver wears a colour chosen for its confidence association.
|
||||||
|
|
||||||
|
#### A20
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner choosing the dress code for a private evening, within limits you both agree first?
|
||||||
|
Partner text: Would you be open to choosing the dress code for a private evening, within limits you both agree first?
|
||||||
|
Intended outcome: One partner leads the evening's agreed presentation and atmosphere.
|
||||||
|
|
||||||
|
#### A21
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to send you a message before your date saying exactly what they hope you will wear?
|
||||||
|
Partner text: Would you like to send your partner a message before your date saying exactly what you hope they will wear?
|
||||||
|
Intended outcome: The receiver gets a style preference message before a date.
|
||||||
|
|
||||||
|
#### A22
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner choosing an accessory for you to wear as a private signal between you?
|
||||||
|
Partner text: Would you enjoy choosing an accessory for your partner to wear as a private signal between you?
|
||||||
|
Intended outcome: The couple establishes a subtle shared signal using an accessory.
|
||||||
|
|
||||||
|
#### A23
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be up for your partner asking you to get ready slowly while they give you one sincere compliment at each step?
|
||||||
|
Partner text: Would you be up for asking your partner to get ready slowly while you give them one sincere compliment at each step?
|
||||||
|
Intended outcome: The proposer gives the receiver a paced confidence ritual before a night together.
|
||||||
|
|
||||||
|
#### A24
|
||||||
|
Theme: Clothes and confidence
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to choose a 'stay in' outfit for you that feels intentionally different from everyday clothes?
|
||||||
|
Partner text: Would you like to choose a 'stay in' outfit for your partner that feels intentionally different from everyday clothes?
|
||||||
|
Intended outcome: The proposer selects a special at-home look for the receiver.
|
||||||
|
|
||||||
|
### Remaining authored asymmetric entries
|
||||||
|
|
||||||
|
The following numbered entries retain the authored Concept Set B material for
|
||||||
|
fantasy exchange, roleplay, settings and atmosphere, surprise and novelty, and
|
||||||
|
long-distance desire. Their primary text, partner text, and intended outcome
|
||||||
|
are deliberately retained verbatim in the task transcript for integration.
|
||||||
|
|
||||||
|
#### A25
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to tell you about a fictional date they would plan for the two of you with no practical limits?
|
||||||
|
Partner text: Would you like to tell your partner about a fictional date you would plan for the two of you with no practical limits?
|
||||||
|
Intended outcome: The proposer shares an imaginative, romance-forward date scenario.
|
||||||
|
|
||||||
|
#### A26
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner writing a three-line scene about the two of you meeting as strangers in a glamorous place?
|
||||||
|
Partner text: Would you enjoy writing a three-line scene about you and your partner meeting as strangers in a glamorous place?
|
||||||
|
Intended outcome: The couple gets a short shared fantasy prompt to discuss or play with.
|
||||||
|
|
||||||
|
#### A27
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you be open to your partner asking what kind of attention makes you feel most wanted in an imagined scenario?
|
||||||
|
Partner text: Would you be open to asking your partner what kind of attention makes them feel most wanted in an imagined scenario?
|
||||||
|
Intended outcome: The receiver names a preferred kind of romantic attention without needing to commit to it.
|
||||||
|
|
||||||
|
#### A28
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to propose a fictional version of tonight where they get to surprise you, and you choose the boundaries first?
|
||||||
|
Partner text: Would you like to propose a fictional version of tonight where you get to surprise your partner, and they choose the boundaries first?
|
||||||
|
Intended outcome: One partner proposes an agreed fantasy frame, with the receiver setting limits.
|
||||||
|
|
||||||
|
#### A29
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to invent a nickname for the more confident version of you?
|
||||||
|
Partner text: Would you like to invent a nickname for the more confident version of your partner?
|
||||||
|
Intended outcome: The couple creates a playful private identity cue.
|
||||||
|
|
||||||
|
#### A30
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner describing the setting of their ideal slow-burn evening with you?
|
||||||
|
Partner text: Would you enjoy describing the setting of your ideal slow-burn evening with your partner?
|
||||||
|
Intended outcome: The proposer shares a detailed atmosphere preference that can inform a future plan.
|
||||||
|
|
||||||
|
#### A31
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner asking you what you would want them to take charge of in an imaginary evening?
|
||||||
|
Partner text: Would you be open to asking your partner what they would want you to take charge of in an imaginary evening?
|
||||||
|
Intended outcome: The receiver identifies one area of leadership they would welcome in fantasy.
|
||||||
|
|
||||||
|
#### A32
|
||||||
|
Theme: Fantasy exchange
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to send you a private message that starts, 'If we had the whole evening to ourselves...' ?
|
||||||
|
Partner text: Would you like to send your partner a private message that starts, 'If we had the whole evening to ourselves...' ?
|
||||||
|
Intended outcome: The proposer sends an open-ended desire prompt for a private exchange.
|
||||||
|
|
||||||
|
#### A33
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to pretend they are meeting you for the first time and ask you out again?
|
||||||
|
Partner text: Would you like to pretend you are meeting your partner for the first time and ask them out again?
|
||||||
|
Intended outcome: The proposer recreates a first-meeting invitation in character.
|
||||||
|
|
||||||
|
#### A34
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner taking the role of your personal host for an evening at home?
|
||||||
|
Partner text: Would you enjoy taking the role of your partner's personal host for an evening at home?
|
||||||
|
Intended outcome: One partner plans and hosts a dedicated private evening.
|
||||||
|
|
||||||
|
#### A35
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you be up for your partner acting as a confident stranger who has one hour to win you over?
|
||||||
|
Partner text: Would you be up for acting as a confident stranger who has one hour to win your partner over?
|
||||||
|
Intended outcome: The proposer takes on a flirtatious stranger character during an agreed private scenario.
|
||||||
|
|
||||||
|
#### A36
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to choose a role where they lead the conversation and you decide when to step out of character?
|
||||||
|
Partner text: Would you like to choose a role where you lead the conversation and your partner decides when to step out of character?
|
||||||
|
Intended outcome: The proposer leads a consensual roleplay conversation with a clear exit controlled by the receiver.
|
||||||
|
|
||||||
|
#### A37
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you enjoy your partner writing you a pretend invitation to an exclusive event for two?
|
||||||
|
Partner text: Would you enjoy writing your partner a pretend invitation to an exclusive event for two?
|
||||||
|
Intended outcome: The proposer creates a playful invitation that introduces a private date concept.
|
||||||
|
|
||||||
|
#### A38
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to play the part of a stylist who gives you one confidence challenge for the evening?
|
||||||
|
Partner text: Would you like to play the part of a stylist who gives your partner one confidence challenge for the evening?
|
||||||
|
Intended outcome: One partner sets a light, agreed confidence challenge in character.
|
||||||
|
|
||||||
|
#### A39
|
||||||
|
Theme: Roleplay
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner choosing a character who is bolder than they usually are, while you set the limits of the scene?
|
||||||
|
Partner text: Would you be open to choosing a character who is bolder than you usually are, while your partner sets the limits of the scene?
|
||||||
|
Intended outcome: The proposer explores a bolder role while the receiver sets the scene's boundaries.
|
||||||
|
|
||||||
|
#### A40
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to turn one room into a date-night space before you arrive?
|
||||||
|
Partner text: Would you like to turn one room into a date-night space before your partner arrives?
|
||||||
|
Intended outcome: The proposer prepares a distinct at-home setting for the receiver.
|
||||||
|
|
||||||
|
#### A41
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner choosing a place in your home where you are not usually romantic and making it feel special?
|
||||||
|
Partner text: Would you enjoy choosing a place in your home where you and your partner are not usually romantic and making it feel special?
|
||||||
|
Intended outcome: One partner transforms an ordinary indoor space into a deliberate romantic setting.
|
||||||
|
|
||||||
|
#### A42
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to plan a candlelit late-night snack for the two of you after everything else is quiet?
|
||||||
|
Partner text: Would you like to plan a candlelit late-night snack for you and your partner after everything else is quiet?
|
||||||
|
Intended outcome: The proposer creates a calm, intimate-feeling post-evening ritual.
|
||||||
|
|
||||||
|
#### A43
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner arranging a private evening where your phones stay out of reach until you both agree to check them?
|
||||||
|
Partner text: Would you be open to arranging a private evening where your phones stay out of reach until you both agree to check them?
|
||||||
|
Intended outcome: The couple agrees to protected, distraction-free private time.
|
||||||
|
|
||||||
|
#### A44
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to choose the route for a slow evening walk that ends somewhere cosy?
|
||||||
|
Partner text: Would you like to choose the route for a slow evening walk that ends somewhere cosy with your partner?
|
||||||
|
Intended outcome: The proposer directs a low-pressure shared walk toward a close setting.
|
||||||
|
|
||||||
|
#### A45
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner planning a 'no agenda' hour where they only focus on making you feel looked after?
|
||||||
|
Partner text: Would you enjoy planning a 'no agenda' hour where you only focus on making your partner feel looked after?
|
||||||
|
Intended outcome: One partner takes responsibility for a gentle, attentiveness-focused hour.
|
||||||
|
|
||||||
|
#### A46
|
||||||
|
Theme: Settings and atmosphere
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to choose a private setting for a conversation about what would make tonight feel exciting to you?
|
||||||
|
Partner text: Would you like to choose a private setting for a conversation about what would make tonight feel exciting to your partner?
|
||||||
|
Intended outcome: The proposer creates the conditions for a direct desire and boundaries conversation.
|
||||||
|
|
||||||
|
#### A47
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to leave you a sealed note with one romantic surprise inside?
|
||||||
|
Partner text: Would you like to leave your partner a sealed note with one romantic surprise inside?
|
||||||
|
Intended outcome: The proposer creates a small, consent-safe romantic surprise for the receiver.
|
||||||
|
|
||||||
|
#### A48
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner planning one part of your evening that they do not reveal until you have both agreed to begin?
|
||||||
|
Partner text: Would you enjoy planning one part of your evening that you do not reveal until you and your partner have both agreed to begin?
|
||||||
|
Intended outcome: One partner prepares a mutually agreed surprise element.
|
||||||
|
|
||||||
|
#### A49
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to give you a choice between two different kinds of close evening, without telling you which they hope you pick?
|
||||||
|
Partner text: Would you like to give your partner a choice between two different kinds of close evening, without telling them which you hope they pick?
|
||||||
|
Intended outcome: The couple uses a playful choice to reveal preferences and select a plan.
|
||||||
|
|
||||||
|
#### A50
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner asking you to say yes to one surprise date-night detail, after they tell you the category but not the detail?
|
||||||
|
Partner text: Would you be open to asking your partner to say yes to one surprise date-night detail, after you tell them the category but not the detail?
|
||||||
|
Intended outcome: The receiver consents to a bounded surprise with enough information to choose safely.
|
||||||
|
|
||||||
|
#### A51
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to hide a clue somewhere at home that leads to a compliment from them?
|
||||||
|
Partner text: Would you like to hide a clue somewhere at home that leads your partner to a compliment from you?
|
||||||
|
Intended outcome: The proposer creates a mini treasure hunt ending in affirmation.
|
||||||
|
|
||||||
|
#### A52
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner choosing a new shared ritual for the first ten minutes of date night?
|
||||||
|
Partner text: Would you enjoy choosing a new shared ritual for the first ten minutes of date night with your partner?
|
||||||
|
Intended outcome: The proposer introduces a repeatable opening ritual for intentional time together.
|
||||||
|
|
||||||
|
#### A53
|
||||||
|
Theme: Surprise and novelty
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to surprise you with a confidence challenge that you can change or stop at any point?
|
||||||
|
Partner text: Would you like to surprise your partner with a confidence challenge that they can change or stop at any point?
|
||||||
|
Intended outcome: The proposer offers a novel confidence prompt with explicit receiver control.
|
||||||
|
|
||||||
|
#### A54
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to send you a voice note describing the first thing they want to do when you next have time alone together?
|
||||||
|
Partner text: Would you like to send your partner a voice note describing the first thing you want to do when you next have time alone together?
|
||||||
|
Intended outcome: The proposer shares an anticipation-building voice message for the next reunion.
|
||||||
|
|
||||||
|
#### A55
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner planning a video-date mood for you to join from wherever you are?
|
||||||
|
Partner text: Would you enjoy planning a video-date mood for your partner to join from wherever they are?
|
||||||
|
Intended outcome: One partner sets a shared remote date atmosphere.
|
||||||
|
|
||||||
|
#### A56
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you like your partner to send you a message telling you what they miss most about being close to you?
|
||||||
|
Partner text: Would you like to send your partner a message telling them what you miss most about being close to them?
|
||||||
|
Intended outcome: The receiver gets a specific intimacy-adjacent expression of longing.
|
||||||
|
|
||||||
|
#### A57
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you be open to your partner arranging a private call where you both say one thing you hope to explore together when you reunite?
|
||||||
|
Partner text: Would you be open to arranging a private call where you both say one thing you hope to explore together when you reunite?
|
||||||
|
Intended outcome: The couple holds a mutual, forward-looking conversation about agreed future intimacy.
|
||||||
|
|
||||||
|
#### A58
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 2
|
||||||
|
Primary text: Would you like your partner to send you a photo of something that reminds them of your chemistry?
|
||||||
|
Partner text: Would you like to send your partner a photo of something that reminds you of your chemistry?
|
||||||
|
Intended outcome: The proposer shares a safe visual cue connected to their relationship.
|
||||||
|
|
||||||
|
#### A59
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 3
|
||||||
|
Primary text: Would you enjoy your partner choosing a song for you to listen to at the same time from different places?
|
||||||
|
Partner text: Would you enjoy choosing a song for you and your partner to listen to at the same time from different places?
|
||||||
|
Intended outcome: The pair creates a synchronized remote closeness ritual.
|
||||||
|
|
||||||
|
#### A60
|
||||||
|
Theme: Long-distance desire
|
||||||
|
Intensity: 4
|
||||||
|
Primary text: Would you like your partner to ask you for one honest promise about how you will make time for each other when distance ends?
|
||||||
|
Partner text: Would you like to ask your partner for one honest promise about how you will make time for each other when distance ends?
|
||||||
|
Intended outcome: The couple makes a concrete reunion-intent commitment.
|
||||||
|
|
||||||
|
## Symmetric concepts
|
||||||
|
|
||||||
|
Each symmetric entry uses the same text as its partner text.
|
||||||
|
|
||||||
|
| ID | Theme | Intensity | Primary text | Partner text | Intended outcome |
|
||||||
|
| --- | --- | ---: | --- | --- | --- |
|
||||||
|
| S01 | Sensory cues | 2 | Would you both be up for choosing a shared scent for your next intentional evening? | Same as primary text. | The couple selects one scent cue for an upcoming date night. |
|
||||||
|
| S02 | Sensory cues | 3 | Would you both enjoy a quiet, eyes-closed music moment together before your next date? | Same as primary text. | The pair agrees to begin a date with focused shared listening. |
|
||||||
|
| S03 | Sensory cues | 3 | Would you both like to make a list of five sounds that help you feel relaxed and close? | Same as primary text. | The couple creates a personal sound-based mood guide. |
|
||||||
|
| S04 | Sensory cues | 4 | Would you both be open to spending ten minutes in silence, staying close and checking in afterward about how it felt? | Same as primary text. | The couple tries a deliberate quiet closeness exercise followed by a check-in. |
|
||||||
|
| S05 | Private performance | 2 | Would you both be up for taking turns giving each other a dramatic, affectionate introduction? | Same as primary text. | Each partner performs a playful confidence-building introduction. |
|
||||||
|
| S06 | Private performance | 3 | Would you both enjoy choosing one song each for a private dance together? | Same as primary text. | The pair shares two intentionally selected dances. |
|
||||||
|
| S07 | Private performance | 3 | Would you both like to read each other a short note about what makes a date feel exciting? | Same as primary text. | Each partner names what builds anticipation for them. |
|
||||||
|
| S08 | Private performance | 4 | Would you both be open to taking turns leading a five-minute close moment, with either person able to pause it? | Same as primary text. | The partners alternate leadership with clear stop control. |
|
||||||
|
| S09 | Clothes and confidence | 2 | Would you both enjoy choosing one small style detail for each other's next date-night look? | Same as primary text. | Each partner receives a playful style suggestion. |
|
||||||
|
| S10 | Clothes and confidence | 3 | Would you both like to wear something that makes you feel more confident than usual for an evening at home? | Same as primary text. | Both partners intentionally dress for confidence at home. |
|
||||||
|
| S11 | Clothes and confidence | 3 | Would you both be up for sharing one compliment about how the other partner looks when they feel most like themselves? | Same as primary text. | Each partner receives an appearance-based affirmation. |
|
||||||
|
| S12 | Clothes and confidence | 4 | Would you both be open to agreeing a private dress code for a date night that feels new to you? | Same as primary text. | The couple jointly chooses a novel, mutually comfortable date-night presentation. |
|
||||||
|
| S13 | Fantasy exchange | 2 | Would you both enjoy inventing an imaginary getaway designed entirely around feeling close? | Same as primary text. | The couple co-creates a romantic fantasy setting. |
|
||||||
|
| S14 | Fantasy exchange | 3 | Would you both like to finish the sentence, 'A version of us with no interruptions would...' ? | Same as primary text. | Each partner shares an open-ended private-time wish. |
|
||||||
|
| S15 | Fantasy exchange | 3 | Would you both be up for naming one fictional setting that makes romance feel more exciting to you? | Same as primary text. | The pair discovers compatible atmosphere preferences. |
|
||||||
|
| S16 | Fantasy exchange | 4 | Would you both be open to making a shared 'maybe someday' list of private experiences you could discuss before trying? | Same as primary text. | The couple creates a consent-first discussion list without committing to any item. |
|
||||||
|
| S17 | Roleplay | 2 | Would you both enjoy pretending you are on a first date and asking each other three new questions? | Same as primary text. | The couple refreshes their connection through a first-date scenario. |
|
||||||
|
| S18 | Roleplay | 3 | Would you both like to choose characters for a one-hour at-home date with a clear time to step out of character? | Same as primary text. | The pair co-designs a bounded, playful roleplay date. |
|
||||||
|
| S19 | Roleplay | 3 | Would you both be up for writing each other an invitation from a fictional admirer? | Same as primary text. | Each partner receives a flirtatious, invented invitation. |
|
||||||
|
| S20 | Roleplay | 4 | Would you both be open to trying a roleplay where one person leads the scene and you switch roles halfway through? | Same as primary text. | Both partners experience leading and following within an agreed fictional scene. |
|
||||||
|
| S21 | Settings and atmosphere | 2 | Would you both enjoy turning off the usual lights and creating a new at-home date setting together? | Same as primary text. | The pair jointly changes the mood of a familiar room. |
|
||||||
|
| S22 | Settings and atmosphere | 3 | Would you both like to plan an evening with one room, one playlist, and no other plans? | Same as primary text. | The couple reserves an intentionally simple, focused evening. |
|
||||||
|
| S23 | Settings and atmosphere | 3 | Would you both be up for creating a shared 'do not disturb' signal for private time at home? | Same as primary text. | The couple agrees on a respectful cue that protects their uninterrupted time. |
|
||||||
|
| S24 | Settings and atmosphere | 4 | Would you both be open to a private evening where you begin by agreeing what would make each of you feel cared for? | Same as primary text. | The couple starts a close evening with mutual needs and limits stated aloud. |
|
||||||
|
| S25 | Surprise and novelty | 2 | Would you both enjoy swapping one sealed note with a date-night idea inside? | Same as primary text. | Each partner contributes one surprise idea for future time together. |
|
||||||
|
| S26 | Surprise and novelty | 3 | Would you both like to choose a new ritual for saying goodbye at the end of a date? | Same as primary text. | The pair establishes a novel end-of-date connection ritual. |
|
||||||
|
| S27 | Surprise and novelty | 4 | Would you both be open to choosing one surprise element for a future evening, with a clear option to change your mind? | Same as primary text. | The couple chooses a bounded surprise while preserving ongoing consent. |
|
||||||
|
| S28 | Long-distance desire | 2 | Would you both enjoy sending each other one voice note that makes the next reunion feel closer? | Same as primary text. | Both partners exchange anticipation-building audio messages. |
|
||||||
|
| S29 | Long-distance desire | 3 | Would you both like to plan the first hour of your next reunion from different places? | Same as primary text. | The couple co-plans a specific reconnecting ritual for their reunion. |
|
||||||
|
| S30 | Long-distance desire | 4 | Would you both be open to a private call where you share one hope and one boundary for your next time alone together? | Same as primary text. | The couple aligns on future closeness through reciprocal desire and boundary sharing. |
|
||||||
|
|
||||||
|
## Count check
|
||||||
|
|
||||||
|
- Asymmetric entries: A01 through A60, 60 total.
|
||||||
|
- Symmetric entries: S01 through S30, 30 total.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
|||||||
|
# Play-safe spicy question draft v1
|
||||||
|
|
||||||
|
Status: source-only, unpublished, not approved, imported, or published. This is an editorial draft for human policy review.
|
||||||
|
|
||||||
|
## Packs
|
||||||
|
|
||||||
|
- Flirty Communication: 55 questions.
|
||||||
|
- Anticipation: 38 questions.
|
||||||
|
- Taking the Lead: 35 questions.
|
||||||
|
- Following: 26 questions.
|
||||||
|
- Shared Control: 24 questions.
|
||||||
|
- Sensory Cues: 41 questions.
|
||||||
|
- Private Performance: 34 questions.
|
||||||
|
- Clothes and Confidence: 40 questions.
|
||||||
|
- Fantasy Exchange: 31 questions.
|
||||||
|
- Roleplay: 29 questions.
|
||||||
|
- Settings and Atmosphere: 40 questions.
|
||||||
|
- Boundaries and Check-ins: 35 questions.
|
||||||
|
- Surprise and Novelty: 33 questions.
|
||||||
|
- Long-distance Desire: 23 questions.
|
||||||
|
- Giving and Receiving Attention: 37 questions.
|
||||||
|
- Aftercare and Reconnection: 29 questions.
|
||||||
|
|
||||||
|
## Exact distribution
|
||||||
|
|
||||||
|
- 550 total: 300 swipe, 100 text_answer, 70 audio, 40 photo, 40 who_likely.
|
||||||
|
- Intensities: 120 at level 2, 300 at level 3, 130 at level 4.
|
||||||
|
- Swipe: 120 asymmetric concepts as 120 primaries plus 120 inverse rows, and 60 symmetric rows.
|
||||||
|
|
||||||
|
## Current in-app outcomes
|
||||||
|
|
||||||
|
- Swipe proposals create a mutual yes or maybe result. Asymmetric rows use reversed partner wording and inverse links.
|
||||||
|
- Text answers let each partner provide the final private detail in their own words.
|
||||||
|
- Audio prompts create a private 60-second spoken result.
|
||||||
|
- Photo prompts request only safe clothed outfits, objects, notes, lighting, and settings.
|
||||||
|
- Who-is-more-likely prompts create a light comparison and a natural private follow-up.
|
||||||
|
|
||||||
|
## Policy boundaries
|
||||||
|
|
||||||
|
Developer-authored wording avoids named sexual acts, anatomy, sex toys, fetish labels, nudity, public sexual activity, coercion, and instructional wording. Intensity 4 uses edge-reviewed language about adult private dynamics, boundaries, roles, surprise, and user-led detail. This is not a claim of Google approval.
|
||||||
|
|
||||||
|
Known limitation: the current API accepts response_data loosely. This source draft assumes no structured compatibility builder or safe user-generated-content moderation envelope, and uses only existing supported question types.
|
||||||
|
|
||||||
|
Run node scripts/validate-question-draft.mjs to verify this source-only contract,
|
||||||
|
including artificial discriminator and repeated-opening checks.
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# Play-safe spicy text-answer concept source
|
||||||
|
|
||||||
|
This source preserves 100 authored `text_answer` concepts for review. It is a draft content source only and is not a database seed or deployment input.
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
1. **Theme:** Flirty communication | **Intensity:** 2
|
||||||
|
**Prompt:** Write the compliment you most want your partner to give you before a close moment.
|
||||||
|
**Intended outcome:** Your partner learns the exact reassurance that makes you feel wanted and can use it as a sincere opening.
|
||||||
|
|
||||||
|
2. **Theme:** Flirty communication | **Intensity:** 2
|
||||||
|
**Prompt:** Finish this sentence for your partner: “When you look at me like that, I want you to know...”
|
||||||
|
**Intended outcome:** Your partner receives a personal signal about the effect their attention has on you.
|
||||||
|
|
||||||
|
3. **Theme:** Flirty communication | **Intensity:** 3
|
||||||
|
**Prompt:** Write a one-line invitation that would make you smile if your partner sent it unexpectedly.
|
||||||
|
**Intended outcome:** Your partner gets a ready-to-use private message and learns the tone that draws you in.
|
||||||
|
|
||||||
|
4. **Theme:** Flirty communication | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the kind of teasing remark that feels playful to you rather than awkward.
|
||||||
|
**Intended outcome:** Your partner learns your preferred style of flirtation and a boundary around what keeps it fun.
|
||||||
|
|
||||||
|
5. **Theme:** Flirty communication | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner one thing they do that immediately changes the mood between you.
|
||||||
|
**Intended outcome:** Your partner discovers a specific behaviour worth repeating when you both want more closeness.
|
||||||
|
|
||||||
|
6. **Theme:** Flirty communication | **Intensity:** 4
|
||||||
|
**Prompt:** Write the boldest thing you would like your partner to say to you privately, in your own words.
|
||||||
|
**Intended outcome:** Your partner receives direct permission to use language that feels exciting to you.
|
||||||
|
|
||||||
|
7. **Theme:** Flirty communication | **Intensity:** 4
|
||||||
|
**Prompt:** Describe a private conversation you would love to have when you both feel especially open.
|
||||||
|
**Intended outcome:** Your partner learns the subject you are curious to explore and can choose a comfortable time to start it.
|
||||||
|
|
||||||
|
8. **Theme:** Anticipation | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the ideal first hint that a special evening together is coming.
|
||||||
|
**Intended outcome:** Your partner learns how to build anticipation in a way that feels welcome rather than vague.
|
||||||
|
|
||||||
|
9. **Theme:** Anticipation | **Intensity:** 2
|
||||||
|
**Prompt:** Write the kind of message that would make you look forward to seeing your partner later.
|
||||||
|
**Intended outcome:** Your partner gets a concrete cue they can send before you reconnect.
|
||||||
|
|
||||||
|
10. **Theme:** Anticipation | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what preparation would make you feel more relaxed before a private date at home.
|
||||||
|
**Intended outcome:** Your partner learns a practical condition that helps you arrive present and receptive.
|
||||||
|
|
||||||
|
11. **Theme:** Anticipation | **Intensity:** 3
|
||||||
|
**Prompt:** Describe a small change to your usual evening routine that would make it feel more intentional.
|
||||||
|
**Intended outcome:** Your partner receives one realistic ritual to create a sense of occasion together.
|
||||||
|
|
||||||
|
12. **Theme:** Anticipation | **Intensity:** 3
|
||||||
|
**Prompt:** Write what you would enjoy being asked to choose before your next intimate evening.
|
||||||
|
**Intended outcome:** Your partner learns where offering you a choice creates more excitement.
|
||||||
|
|
||||||
|
13. **Theme:** Anticipation | **Intensity:** 4
|
||||||
|
**Prompt:** Share one private surprise you would genuinely enjoy your partner planning for you.
|
||||||
|
**Intended outcome:** Your partner receives a clear idea for an unexpected gesture, including the kind of surprise you welcome.
|
||||||
|
|
||||||
|
14. **Theme:** Anticipation | **Intensity:** 4
|
||||||
|
**Prompt:** Write a short countdown message you would like to receive before a night focused on the two of you.
|
||||||
|
**Intended outcome:** Your partner gets language that builds suspense without guessing what would land well.
|
||||||
|
|
||||||
|
15. **Theme:** Taking the lead | **Intensity:** 2
|
||||||
|
**Prompt:** Describe one part of a date where you would enjoy deciding what happens next.
|
||||||
|
**Intended outcome:** Your partner learns a low-pressure moment where your initiative would feel attractive.
|
||||||
|
|
||||||
|
16. **Theme:** Taking the lead | **Intensity:** 2
|
||||||
|
**Prompt:** Write an invitation you would feel confident making when you want more one-to-one time.
|
||||||
|
**Intended outcome:** Your partner receives a phrase that makes your interest unmistakable and easy to answer.
|
||||||
|
|
||||||
|
17. **Theme:** Taking the lead | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what kind of plan you would enjoy taking charge of from start to finish.
|
||||||
|
**Intended outcome:** Your partner learns which shared experience you want to own and can leave space for you to lead it.
|
||||||
|
|
||||||
|
18. **Theme:** Taking the lead | **Intensity:** 3
|
||||||
|
**Prompt:** Describe how you would like your partner to respond when you make a bold suggestion.
|
||||||
|
**Intended outcome:** Your partner gets a practical guide for encouraging your confidence in the moment.
|
||||||
|
|
||||||
|
19. **Theme:** Taking the lead | **Intensity:** 3
|
||||||
|
**Prompt:** Write one decision you would like to make for both of you during a private evening.
|
||||||
|
**Intended outcome:** Your partner learns the kind of authority you want to try, with a concrete decision to hand over.
|
||||||
|
|
||||||
|
20. **Theme:** Taking the lead | **Intensity:** 4
|
||||||
|
**Prompt:** Describe a confident version of yourself you would like your partner to see more often.
|
||||||
|
**Intended outcome:** Your partner learns what confidence looks like for you and how they can invite it out.
|
||||||
|
|
||||||
|
21. **Theme:** Taking the lead | **Intensity:** 4
|
||||||
|
**Prompt:** Write the private challenge you would like to set for your partner, including what would make it exciting.
|
||||||
|
**Intended outcome:** Your partner receives a consensual challenge with the reason it appeals to you.
|
||||||
|
|
||||||
|
22. **Theme:** Following | **Intensity:** 2
|
||||||
|
**Prompt:** Tell your partner one decision you would enjoy not having to make on a date.
|
||||||
|
**Intended outcome:** Your partner learns a simple place where taking initiative would feel caring.
|
||||||
|
|
||||||
|
23. **Theme:** Following | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the kind of invitation that makes it easy for you to say yes.
|
||||||
|
**Intended outcome:** Your partner learns the wording and atmosphere that lower pressure for you.
|
||||||
|
|
||||||
|
24. **Theme:** Following | **Intensity:** 3
|
||||||
|
**Prompt:** Write what helps you trust your partner’s lead during a more intimate moment.
|
||||||
|
**Intended outcome:** Your partner learns the specific reassurance or check-in that helps you relax into their plan.
|
||||||
|
|
||||||
|
25. **Theme:** Following | **Intensity:** 3
|
||||||
|
**Prompt:** Share one surprise choice you would enjoy your partner making for you.
|
||||||
|
**Intended outcome:** Your partner gets a defined area where surprise feels exciting and safe.
|
||||||
|
|
||||||
|
26. **Theme:** Following | **Intensity:** 3
|
||||||
|
**Prompt:** Describe how you want your partner to check that you are still enjoying their lead.
|
||||||
|
**Intended outcome:** Your partner receives a clear check-in preference they can use without breaking the mood.
|
||||||
|
|
||||||
|
27. **Theme:** Following | **Intensity:** 4
|
||||||
|
**Prompt:** Write about a private situation where you would like to be guided step by step, in your own terms.
|
||||||
|
**Intended outcome:** Your partner learns the type of guidance you are curious about and the limits you want respected.
|
||||||
|
|
||||||
|
28. **Theme:** Following | **Intensity:** 4
|
||||||
|
**Prompt:** Tell your partner what would make it feel exciting to let them choose the pace for a while.
|
||||||
|
**Intended outcome:** Your partner learns the conditions that make you comfortable giving them more influence.
|
||||||
|
|
||||||
|
29. **Theme:** Shared control | **Intensity:** 2
|
||||||
|
**Prompt:** Name one decision you want to make together before your next close evening.
|
||||||
|
**Intended outcome:** Your partner learns where collaboration matters most to you and can plan it jointly.
|
||||||
|
|
||||||
|
30. **Theme:** Shared control | **Intensity:** 2
|
||||||
|
**Prompt:** Describe your ideal signal for “let’s keep going” during a private moment.
|
||||||
|
**Intended outcome:** Your partner receives a simple shared cue that makes mutual enthusiasm easier to read.
|
||||||
|
|
||||||
|
31. **Theme:** Shared control | **Intensity:** 3
|
||||||
|
**Prompt:** Write the rule you would like both of you to follow when trying something unfamiliar together.
|
||||||
|
**Intended outcome:** Your partner learns a mutual agreement that makes exploration feel safer.
|
||||||
|
|
||||||
|
32. **Theme:** Shared control | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner how you would like to alternate who chooses the next part of a date.
|
||||||
|
**Intended outcome:** Your partner gets a workable rhythm for sharing initiative rather than defaulting to one person.
|
||||||
|
|
||||||
|
33. **Theme:** Shared control | **Intensity:** 3
|
||||||
|
**Prompt:** Describe one moment where you would enjoy pausing together to decide what you both want.
|
||||||
|
**Intended outcome:** Your partner learns when a joint decision would add connection instead of interrupting it.
|
||||||
|
|
||||||
|
34. **Theme:** Shared control | **Intensity:** 4
|
||||||
|
**Prompt:** Write the private agreement that would make you feel brave enough to explore more together.
|
||||||
|
**Intended outcome:** Your partner receives a concrete mutual promise that supports bolder, consent-led choices.
|
||||||
|
|
||||||
|
35. **Theme:** Shared control | **Intensity:** 4
|
||||||
|
**Prompt:** Share the question you want your partner to ask before either of you raises the intensity.
|
||||||
|
**Intended outcome:** Your partner learns the exact invitation to use before moving into more charged territory.
|
||||||
|
|
||||||
|
36. **Theme:** Boundaries and check-ins | **Intensity:** 2
|
||||||
|
**Prompt:** Write one phrase that means “I like this, but slower” for you.
|
||||||
|
**Intended outcome:** Your partner receives a clear signal for adjusting pace without uncertainty.
|
||||||
|
|
||||||
|
37. **Theme:** Boundaries and check-ins | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the best way for your partner to ask whether you are comfortable.
|
||||||
|
**Intended outcome:** Your partner learns a check-in style that feels caring rather than clinical.
|
||||||
|
|
||||||
|
38. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner one thing you need before you can enjoy a more adventurous private plan.
|
||||||
|
**Intended outcome:** Your partner learns a condition that turns curiosity into a safer shared possibility.
|
||||||
|
|
||||||
|
39. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||||
|
**Prompt:** Write a kind way you would want your partner to respond if you change your mind.
|
||||||
|
**Intended outcome:** Your partner gets direct guidance for making a change of plan feel respected and easy.
|
||||||
|
|
||||||
|
40. **Theme:** Boundaries and check-ins | **Intensity:** 3
|
||||||
|
**Prompt:** Describe one topic you would prefer to discuss before, rather than during, a close moment.
|
||||||
|
**Intended outcome:** Your partner learns what deserves a calm conversation in advance.
|
||||||
|
|
||||||
|
41. **Theme:** Boundaries and check-ins | **Intensity:** 4
|
||||||
|
**Prompt:** Share a boundary that makes you feel more able to be adventurous within it.
|
||||||
|
**Intended outcome:** Your partner learns the limit that creates freedom rather than reducing the mood.
|
||||||
|
|
||||||
|
42. **Theme:** Boundaries and check-ins | **Intensity:** 4
|
||||||
|
**Prompt:** Write the reassurance you need when you suggest something that feels vulnerable to say aloud.
|
||||||
|
**Intended outcome:** Your partner receives a practical response that helps you bring up bolder ideas honestly.
|
||||||
|
|
||||||
|
43. **Theme:** Aftercare and reconnection | **Intensity:** 2
|
||||||
|
**Prompt:** Describe how you like to reconnect after an especially close moment.
|
||||||
|
**Intended outcome:** Your partner learns the small act that helps you feel settled and connected afterward.
|
||||||
|
|
||||||
|
44. **Theme:** Aftercare and reconnection | **Intensity:** 2
|
||||||
|
**Prompt:** Write the first question you would like your partner to ask after trying something new together.
|
||||||
|
**Intended outcome:** Your partner receives a ready-made invitation for a thoughtful debrief.
|
||||||
|
|
||||||
|
45. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what makes you feel appreciated after you have been emotionally open.
|
||||||
|
**Intended outcome:** Your partner learns how to recognise your vulnerability in a way that lands.
|
||||||
|
|
||||||
|
46. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the kind of quiet time you would enjoy sharing once the excitement has passed.
|
||||||
|
**Intended outcome:** Your partner gets a concrete picture of the calm connection you want afterward.
|
||||||
|
|
||||||
|
47. **Theme:** Aftercare and reconnection | **Intensity:** 3
|
||||||
|
**Prompt:** Write one thing you would like to hear after you take a private risk together.
|
||||||
|
**Intended outcome:** Your partner learns the affirmation that helps you feel proud rather than self-conscious.
|
||||||
|
|
||||||
|
48. **Theme:** Aftercare and reconnection | **Intensity:** 4
|
||||||
|
**Prompt:** Share the part of a close experience you would most want to talk through afterward.
|
||||||
|
**Intended outcome:** Your partner learns what reflection would deepen trust and make a future experience better.
|
||||||
|
|
||||||
|
49. **Theme:** Giving and receiving attention | **Intensity:** 2
|
||||||
|
**Prompt:** Describe one way your partner can give you their full attention that you rarely ask for.
|
||||||
|
**Intended outcome:** Your partner discovers a specific act of focus that makes you feel prioritised.
|
||||||
|
|
||||||
|
50. **Theme:** Giving and receiving attention | **Intensity:** 2
|
||||||
|
**Prompt:** Write about a moment when you feel most noticed by your partner.
|
||||||
|
**Intended outcome:** Your partner learns the context in which their attention has the greatest effect.
|
||||||
|
|
||||||
|
51. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what you enjoy doing when you want to make them feel desired.
|
||||||
|
**Intended outcome:** Your partner learns how you naturally show interest and can recognise the signal.
|
||||||
|
|
||||||
|
52. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the kind of praise you most enjoy receiving privately.
|
||||||
|
**Intended outcome:** Your partner gets a personal guide to language that makes you feel seen.
|
||||||
|
|
||||||
|
53. **Theme:** Giving and receiving attention | **Intensity:** 3
|
||||||
|
**Prompt:** Write one request you would enjoy hearing from your partner when they want your attention.
|
||||||
|
**Intended outcome:** Your partner receives a low-pressure way to ask directly for more of you.
|
||||||
|
|
||||||
|
54. **Theme:** Giving and receiving attention | **Intensity:** 4
|
||||||
|
**Prompt:** Share the private gesture that would make you feel unmistakably chosen by your partner.
|
||||||
|
**Intended outcome:** Your partner learns a specific, high-impact way to make you feel desired.
|
||||||
|
|
||||||
|
55. **Theme:** Sensory cues | **Intensity:** 2
|
||||||
|
**Prompt:** Describe a scent that instantly makes an evening feel more intimate to you.
|
||||||
|
**Intended outcome:** Your partner learns an atmosphere cue they can use when planning a close night in.
|
||||||
|
|
||||||
|
56. **Theme:** Sensory cues | **Intensity:** 2
|
||||||
|
**Prompt:** Write about the music that would help you leave everyday stress behind together.
|
||||||
|
**Intended outcome:** Your partner gets a mood-setting choice that supports a more connected evening.
|
||||||
|
|
||||||
|
57. **Theme:** Sensory cues | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner which kind of lighting makes you feel most confident and relaxed.
|
||||||
|
**Intended outcome:** Your partner learns a practical way to shape the room around your comfort.
|
||||||
|
|
||||||
|
58. **Theme:** Sensory cues | **Intensity:** 3
|
||||||
|
**Prompt:** Describe a texture, fabric, or cosy detail that makes a private setting feel special.
|
||||||
|
**Intended outcome:** Your partner receives an actionable detail for creating a setting that appeals to you.
|
||||||
|
|
||||||
|
59. **Theme:** Sensory cues | **Intensity:** 3
|
||||||
|
**Prompt:** Write about the sound of your partner’s voice that you find most captivating.
|
||||||
|
**Intended outcome:** Your partner learns a vocal quality that makes you feel drawn closer.
|
||||||
|
|
||||||
|
60. **Theme:** Sensory cues | **Intensity:** 4
|
||||||
|
**Prompt:** Share the full sensory mood you would like your partner to create for an intimate evening.
|
||||||
|
**Intended outcome:** Your partner receives a personal brief for a more immersive, intentional atmosphere.
|
||||||
|
|
||||||
|
61. **Theme:** Private performance | **Intensity:** 2
|
||||||
|
**Prompt:** Write about a playful thing you would enjoy doing just to make your partner smile in private.
|
||||||
|
**Intended outcome:** Your partner learns a personal expression of your confidence they can encourage.
|
||||||
|
|
||||||
|
62. **Theme:** Private performance | **Intensity:** 2
|
||||||
|
**Prompt:** Describe a song that would make you feel expressive if you had the room to yourselves.
|
||||||
|
**Intended outcome:** Your partner gets a soundtrack that invites a more playful side of you out.
|
||||||
|
|
||||||
|
63. **Theme:** Private performance | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what kind of attention helps you feel confident being watched by them.
|
||||||
|
**Intended outcome:** Your partner learns how to make their attention feel encouraging rather than exposing.
|
||||||
|
|
||||||
|
64. **Theme:** Private performance | **Intensity:** 3
|
||||||
|
**Prompt:** Write a private dare you would enjoy accepting from your partner.
|
||||||
|
**Intended outcome:** Your partner receives a consent-led challenge that fits your idea of playful boldness.
|
||||||
|
|
||||||
|
65. **Theme:** Private performance | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the reaction from your partner that would make you want to be more expressive.
|
||||||
|
**Intended outcome:** Your partner learns the feedback that builds your confidence in the moment.
|
||||||
|
|
||||||
|
66. **Theme:** Private performance | **Intensity:** 4
|
||||||
|
**Prompt:** Share a private moment where you would enjoy being the centre of your partner’s attention.
|
||||||
|
**Intended outcome:** Your partner learns the kind of focused attention you are curious to receive and can plan it with you.
|
||||||
|
|
||||||
|
67. **Theme:** Clothes and confidence | **Intensity:** 2
|
||||||
|
**Prompt:** Describe an outfit that makes you feel more like yourself on a date with your partner.
|
||||||
|
**Intended outcome:** Your partner learns the look that supports your confidence and can compliment it specifically.
|
||||||
|
|
||||||
|
68. **Theme:** Clothes and confidence | **Intensity:** 2
|
||||||
|
**Prompt:** Write the colour you would love your partner to notice you wearing.
|
||||||
|
**Intended outcome:** Your partner gets a small visual detail to notice and celebrate.
|
||||||
|
|
||||||
|
69. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what you would enjoy choosing for them to wear on a private date night.
|
||||||
|
**Intended outcome:** Your partner learns your taste and has a clear invitation to let you style part of the evening.
|
||||||
|
|
||||||
|
70. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the compliment about your appearance that would feel most meaningful from your partner.
|
||||||
|
**Intended outcome:** Your partner receives guidance for praise that strengthens your confidence.
|
||||||
|
|
||||||
|
71. **Theme:** Clothes and confidence | **Intensity:** 3
|
||||||
|
**Prompt:** Write about one detail of getting ready that makes you feel more alluring.
|
||||||
|
**Intended outcome:** Your partner learns the ritual that helps you shift into a more confident mood.
|
||||||
|
|
||||||
|
72. **Theme:** Clothes and confidence | **Intensity:** 4
|
||||||
|
**Prompt:** Share the private look you would be excited to reveal to your partner, in your own words.
|
||||||
|
**Intended outcome:** Your partner learns what kind of reveal feels exciting to you and can respond with the right attention.
|
||||||
|
|
||||||
|
73. **Theme:** Fantasy exchange | **Intensity:** 2
|
||||||
|
**Prompt:** Write about a fictional romance scene whose mood you would love to borrow for a date.
|
||||||
|
**Intended outcome:** Your partner learns the emotional tone you are drawn to without needing to guess the reference.
|
||||||
|
|
||||||
|
74. **Theme:** Fantasy exchange | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the kind of imagined setting that makes you feel more adventurous with your partner.
|
||||||
|
**Intended outcome:** Your partner receives a setting idea that can inspire a themed evening.
|
||||||
|
|
||||||
|
75. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what role you would enjoy imagining for yourself during a playful private scenario.
|
||||||
|
**Intended outcome:** Your partner learns the character energy you are curious to try.
|
||||||
|
|
||||||
|
76. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||||
|
**Prompt:** Write a fictional opening line your partner could use to begin a shared fantasy.
|
||||||
|
**Intended outcome:** Your partner gets a tailored starting line for playful imagination together.
|
||||||
|
|
||||||
|
77. **Theme:** Fantasy exchange | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the one detail that would make an imagined scenario feel believable and exciting to you.
|
||||||
|
**Intended outcome:** Your partner learns the ingredient that turns a vague idea into something engaging.
|
||||||
|
|
||||||
|
78. **Theme:** Fantasy exchange | **Intensity:** 4
|
||||||
|
**Prompt:** Share a private fantasy premise you would like to explore only in conversation first.
|
||||||
|
**Intended outcome:** Your partner learns a higher-intensity idea while the first follow-up remains a safe discussion.
|
||||||
|
|
||||||
|
79. **Theme:** Roleplay | **Intensity:** 2
|
||||||
|
**Prompt:** Write the nickname you would enjoy your partner using during a playful private game.
|
||||||
|
**Intended outcome:** Your partner receives language that helps set a playful tone you welcome.
|
||||||
|
|
||||||
|
80. **Theme:** Roleplay | **Intensity:** 2
|
||||||
|
**Prompt:** Describe a simple character trait you would enjoy your partner pretending to have for one evening.
|
||||||
|
**Intended outcome:** Your partner gets a low-stakes roleplay cue that can make an ordinary night feel new.
|
||||||
|
|
||||||
|
81. **Theme:** Roleplay | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner the kind of made-up first meeting you would enjoy acting out together.
|
||||||
|
**Intended outcome:** Your partner learns a shared scenario that gives both of you a clear starting point.
|
||||||
|
|
||||||
|
82. **Theme:** Roleplay | **Intensity:** 3
|
||||||
|
**Prompt:** Write the setting where you would enjoy pretending you have just met your partner.
|
||||||
|
**Intended outcome:** Your partner receives a scene to build around for a playful reset of familiar chemistry.
|
||||||
|
|
||||||
|
83. **Theme:** Roleplay | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the one rule that would help roleplay feel comfortable and fun for you.
|
||||||
|
**Intended outcome:** Your partner learns the agreement that keeps the game connected to your real trust.
|
||||||
|
|
||||||
|
84. **Theme:** Roleplay | **Intensity:** 4
|
||||||
|
**Prompt:** Share a bolder character dynamic you are curious to discuss with your partner before trying it.
|
||||||
|
**Intended outcome:** Your partner learns what dynamic interests you and can talk through comfort, limits, and interest first.
|
||||||
|
|
||||||
|
85. **Theme:** Settings and atmosphere | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the room where you would most like to have an uninterrupted evening with your partner.
|
||||||
|
**Intended outcome:** Your partner learns which environment helps you feel most present together.
|
||||||
|
|
||||||
|
86. **Theme:** Settings and atmosphere | **Intensity:** 2
|
||||||
|
**Prompt:** Write the first thing you would change at home to make a regular night feel more special.
|
||||||
|
**Intended outcome:** Your partner gets a realistic way to shift an ordinary evening into a date.
|
||||||
|
|
||||||
|
87. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what makes a hotel-style evening feel exciting to you.
|
||||||
|
**Intended outcome:** Your partner learns the elements of novelty or escape you would like recreated.
|
||||||
|
|
||||||
|
88. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||||
|
**Prompt:** Describe the kind of privacy you need before you can fully switch off together.
|
||||||
|
**Intended outcome:** Your partner learns the practical conditions needed for you to feel unhurried.
|
||||||
|
|
||||||
|
89. **Theme:** Settings and atmosphere | **Intensity:** 3
|
||||||
|
**Prompt:** Write about a place from a past date that you would love to recreate the feeling of at home.
|
||||||
|
**Intended outcome:** Your partner receives an emotional reference point and ideas for bringing it back.
|
||||||
|
|
||||||
|
90. **Theme:** Settings and atmosphere | **Intensity:** 4
|
||||||
|
**Prompt:** Share the most adventurous private setting you would enjoy planning together, while staying within your comfort zone.
|
||||||
|
**Intended outcome:** Your partner learns your appetite for novelty and the limits that make the plan feel right.
|
||||||
|
|
||||||
|
91. **Theme:** Surprise and novelty | **Intensity:** 2
|
||||||
|
**Prompt:** Write one small unexpected gesture that would brighten a normal day with your partner.
|
||||||
|
**Intended outcome:** Your partner gets a specific surprise that signals private affection.
|
||||||
|
|
||||||
|
92. **Theme:** Surprise and novelty | **Intensity:** 2
|
||||||
|
**Prompt:** Describe the kind of spontaneous invitation you would be happy to receive.
|
||||||
|
**Intended outcome:** Your partner learns how to be impulsive in a way that still feels welcome.
|
||||||
|
|
||||||
|
93. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner one familiar routine you would enjoy changing up together.
|
||||||
|
**Intended outcome:** Your partner learns where novelty would improve your shared rhythm.
|
||||||
|
|
||||||
|
94. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||||
|
**Prompt:** Write a sealed-note prompt you would enjoy your partner giving you to open later.
|
||||||
|
**Intended outcome:** Your partner receives a private prompt format that can create suspense across the day.
|
||||||
|
|
||||||
|
95. **Theme:** Surprise and novelty | **Intensity:** 3
|
||||||
|
**Prompt:** Describe a new rule for one evening that would make it feel different from your usual time together.
|
||||||
|
**Intended outcome:** Your partner gets a simple novelty experiment that you have already signalled interest in.
|
||||||
|
|
||||||
|
96. **Theme:** Surprise and novelty | **Intensity:** 4
|
||||||
|
**Prompt:** Share the bold surprise you would enjoy discovering after your partner has checked the broad limits with you.
|
||||||
|
**Intended outcome:** Your partner learns both the surprise you want and the consent conversation needed before planning it.
|
||||||
|
|
||||||
|
97. **Theme:** Long-distance desire | **Intensity:** 2
|
||||||
|
**Prompt:** Write the message that would make you feel closest to your partner when you are apart.
|
||||||
|
**Intended outcome:** Your partner learns the kind of connection that travels well between visits.
|
||||||
|
|
||||||
|
98. **Theme:** Long-distance desire | **Intensity:** 2
|
||||||
|
**Prompt:** Describe a tiny ritual you would like to share on the evenings you cannot be together.
|
||||||
|
**Intended outcome:** Your partner receives a repeatable way to maintain intimacy across distance.
|
||||||
|
|
||||||
|
99. **Theme:** Long-distance desire | **Intensity:** 3
|
||||||
|
**Prompt:** Tell your partner what you would like to plan together before your next time in the same place.
|
||||||
|
**Intended outcome:** Your partner learns which shared anticipation would make a reunion more meaningful.
|
||||||
|
|
||||||
|
100. **Theme:** Long-distance desire | **Intensity:** 4
|
||||||
|
**Prompt:** Write the private promise you would like to exchange before a longer period apart.
|
||||||
|
**Intended outcome:** Your partner receives a personal commitment that can sustain desire, reassurance, and connection until you reunite.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Question interaction types
|
||||||
|
|
||||||
|
This is the maintained contract for the question interactions delivered through
|
||||||
|
the daily question flow. It is separate from the [couples quiz](quiz.md) and
|
||||||
|
[dares loop](dares-loop.md), which have their own content and completion
|
||||||
|
models.
|
||||||
|
|
||||||
|
<!-- question-types: swipe,text_answer,audio,photo,who_likely -->
|
||||||
|
|
||||||
|
## Supported types
|
||||||
|
|
||||||
|
| Type | User interaction | `response_data` and `config` | Match semantics |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `swipe` | Choose yes or no, or skip. | No `response_data`; `config` is unused. | A no creates no match. The API also retains `maybe` matching for stored or replayed answers, producing `yes_maybe` or `maybe_maybe`; the current mobile card does not offer maybe. |
|
||||||
|
| `text_answer` | Write an answer and send it, decline with no, or skip. | `response_data`: `{ type: 'text_answer', text }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||||
|
| `audio` | Record and send an audio answer, or skip. | `response_data`: `{ type: 'audio', media_path, duration_seconds }`; optional `config.max_duration_seconds` limits recording and defaults to 60 seconds in the admin UI. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||||
|
| `photo` | Take or select and send a photo answer, or skip. | `response_data`: `{ type: 'photo', media_path }`; `config` is unused. | Both partners must answer without a no. The match type is `both_answered`, with both payloads in `response_summary`. |
|
||||||
|
| `who_likely` | Choose which partner is more likely. The current mobile flow submits this as yes. | `response_data`: `{ type: 'who_likely', chosen_user_id }`; `config` is unused. | Once both partners respond, the match type is `both_answered`; the stored answer value does not gate that type's match calculation. Both choices are in `response_summary`. |
|
||||||
|
|
||||||
|
## Persistence and implementation ownership
|
||||||
|
|
||||||
|
`questions.question_type` defaults to `swipe`; the database check constraint and
|
||||||
|
the canonical shared `QuestionType` union must contain the same values.
|
||||||
|
`questions.config` is JSON configuration for a type. `responses.response_data`
|
||||||
|
stores an individual non-swipe payload. `matches.response_summary` is a JSON
|
||||||
|
object keyed by responder user ID and is populated for `both_answered` matches.
|
||||||
|
|
||||||
|
The canonical list is `QuestionType` in
|
||||||
|
[`packages/shared/src/types/index.ts`](../packages/shared/src/types/index.ts).
|
||||||
|
The database constraint is in
|
||||||
|
[`apps/api/drizzle/0003_answers_matches.sql`](../apps/api/drizzle/0003_answers_matches.sql).
|
||||||
|
Match calculation is in
|
||||||
|
[`apps/api/src/domains/answers/types.ts`](../apps/api/src/domains/answers/types.ts),
|
||||||
|
the mobile renderer is
|
||||||
|
[`apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx`](../apps/mobile/src/features/swipe/components/SwipeQuestionCard.tsx),
|
||||||
|
and admin authoring options are in
|
||||||
|
[`apps/admin/src/pages/content/QuestionsPage.tsx`](../apps/admin/src/pages/content/QuestionsPage.tsx).
|
||||||
|
|
||||||
|
## Change rule
|
||||||
|
|
||||||
|
When adding, removing, or changing a question type, its payload, configuration,
|
||||||
|
or matching semantics, update this document in the same change. Update the
|
||||||
|
shared type, database constraint, API, mobile, and admin consumers as required.
|
||||||
|
`npm run lint:harness` checks the documented type list against the canonical
|
||||||
|
shared union so an unreviewed list change fails routine verification.
|
||||||
+20
-5
@@ -47,6 +47,8 @@ erDiagram
|
|||||||
uuid pack_id FK
|
uuid pack_id FK
|
||||||
text text
|
text text
|
||||||
text partner_text "for two-part questions"
|
text partner_text "for two-part questions"
|
||||||
|
text question_type "swipe/text_answer/audio/photo/who_likely"
|
||||||
|
jsonb config "type-specific configuration"
|
||||||
int intensity "1-5"
|
int intensity "1-5"
|
||||||
text[] allowed_couple_genders "couple composition filter"
|
text[] allowed_couple_genders "couple composition filter"
|
||||||
text[] target_user_genders "individual user filter"
|
text[] target_user_genders "individual user filter"
|
||||||
@@ -70,6 +72,7 @@ erDiagram
|
|||||||
uuid question_id FK
|
uuid question_id FK
|
||||||
uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing"
|
uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing"
|
||||||
answer_type answer "yes/no/maybe"
|
answer_type answer "yes/no/maybe"
|
||||||
|
jsonb response_data "non-swipe answer payload"
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +80,8 @@ erDiagram
|
|||||||
uuid id PK
|
uuid id PK
|
||||||
uuid couple_id FK
|
uuid couple_id FK
|
||||||
uuid question_id FK
|
uuid question_id FK
|
||||||
match_type match_type "yes_yes/yes_maybe/maybe_maybe"
|
match_type match_type "yes_yes/yes_maybe/maybe_maybe/both_answered"
|
||||||
|
jsonb response_summary "both non-swipe payloads by user"
|
||||||
boolean is_new
|
boolean is_new
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
@@ -161,7 +165,8 @@ erDiagram
|
|||||||
| Enum | Values |
|
| Enum | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `answer_type` | `yes`, `no`, `maybe` |
|
| `answer_type` | `yes`, `no`, `maybe` |
|
||||||
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe` |
|
| `question_type` | `swipe`, `text_answer`, `audio`, `photo`, `who_likely` |
|
||||||
|
| `match_type` | `yes_yes`, `yes_maybe`, `maybe_maybe`, `both_answered` |
|
||||||
| `feedback_type` | `bug`, `feature_request`, `general` |
|
| `feedback_type` | `bug`, `feature_request`, `general` |
|
||||||
| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` |
|
| `feedback_status` | `new`, `reviewed`, `in_progress`, `resolved`, `closed` |
|
||||||
| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` |
|
| `subscription_status` | `active`, `cancelled`, `expired`, `billing_issue`, `paused` |
|
||||||
@@ -188,11 +193,21 @@ allowed.
|
|||||||
|
|
||||||
## Key Flows
|
## Key Flows
|
||||||
|
|
||||||
|
### Question interactions
|
||||||
|
|
||||||
|
`questions.question_type` defaults to `swipe`; `questions.config` holds
|
||||||
|
type-specific settings. A non-swipe response keeps its payload in
|
||||||
|
`responses.response_data`. When a non-swipe interaction resolves as
|
||||||
|
`both_answered`, `matches.response_summary` holds both payloads keyed by user
|
||||||
|
ID. The complete interaction and match contract is maintained in
|
||||||
|
[Question interaction types](question-types.md).
|
||||||
|
|
||||||
### Response & Match Flow
|
### Response & Match Flow
|
||||||
1. User submits response via `submit-response` edge function
|
1. User submits a response to the standalone API at `POST /v1/responses`
|
||||||
2. Response saved to `responses` table (UPSERT on `user_id, question_id`)
|
2. Response saved to `responses` table (UPSERT on `user_id, question_id`)
|
||||||
3. Function checks for partner's response on same question
|
3. API checks for the partner's response on the same question
|
||||||
4. If both answered positively (not "no"), a `match` is created
|
4. API applies the [question interaction contract](question-types.md) to create,
|
||||||
|
update, or remove a `match`
|
||||||
5. Match triggers push notification to both partners
|
5. Match triggers push notification to both partners
|
||||||
6. Match unlocks chat thread in `messages` table
|
6. Match unlocks chat thread in `messages` table
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { access, readFile, readdir } from 'node:fs/promises';
|
import { access, readFile, readdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
const root = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const scoped = process.argv.includes('--scope');
|
const scoped = process.argv.includes('--scope');
|
||||||
|
|
||||||
@@ -156,6 +157,22 @@ if (!scoped) {
|
|||||||
if (!(await exists(target))) fail(path.join(root, 'docs', 'index.md'), `broken documentation link ${match[1]}`, 'correct the link or restore the maintained document');
|
if (!(await exists(target))) fail(path.join(root, 'docs', 'index.md'), `broken documentation link ${match[1]}`, 'correct the link or restore the maintained document');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sharedTypesFile = path.join(root, 'packages', 'shared', 'src', 'types', 'index.ts');
|
||||||
|
const sharedTypes = await readFile(sharedTypesFile, 'utf8');
|
||||||
|
const union = sharedTypes.match(/export type QuestionType\s*=\s*([^;]+);/);
|
||||||
|
const canonicalQuestionTypes = union?.[1].match(/'([^']+)'/g)?.map((value) => value.slice(1, -1));
|
||||||
|
const questionTypesDoc = path.join(root, 'docs', 'question-types.md');
|
||||||
|
const questionTypesSource = await readFile(questionTypesDoc, 'utf8');
|
||||||
|
const documentedQuestionTypes = questionTypesSource.match(/<!-- question-types:\s*([^>]+)\s*-->/)?.[1]
|
||||||
|
.split(',').map((value) => value.trim()).filter(Boolean);
|
||||||
|
if (!canonicalQuestionTypes?.length) {
|
||||||
|
fail(sharedTypesFile, 'QuestionType union could not be read', 'keep QuestionType as a quoted string union');
|
||||||
|
} else if (!documentedQuestionTypes?.length) {
|
||||||
|
fail(questionTypesDoc, 'question type contract marker is missing', 'add <!-- question-types: type-a,type-b --> with every supported type');
|
||||||
|
} else if (canonicalQuestionTypes.join(',') !== documentedQuestionTypes.join(',')) {
|
||||||
|
fail(questionTypesDoc, 'documented question types differ from the shared QuestionType union', `set the contract marker to ${canonicalQuestionTypes.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
const mcpAuth = await readFile(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'utf8');
|
const mcpAuth = await readFile(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'utf8');
|
||||||
if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) {
|
if (/allowing all requests|query\(['"]apiKey/i.test(mcpAuth)) {
|
||||||
fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed');
|
fail(path.join(root, 'apps', 'mcp', 'src', 'lib', 'auth.ts'), 'MCP authentication can leak or fail open', 'require a configured Bearer token and fail closed');
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const snapshotPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.json');
|
||||||
|
const checksumPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.sha256');
|
||||||
|
const v1Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-v1.json');
|
||||||
|
const v2Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-extension-v2.json');
|
||||||
|
const markdownPath = resolve(root, 'docs/product/question-drafts/full-live-catalogue-1650.md');
|
||||||
|
|
||||||
|
const allowPacks = new Set([
|
||||||
|
'7e9cb72e-95dd-4140-8d58-17206e6fe153','8da8dcb1-0b9b-4828-9a30-199758b8ade5','e11bdfea-ab07-46a9-952d-5c00c8b4ddbb','cf483ec0-502a-41ea-a2a2-2f223e7ec6ce','877d8a39-5229-4263-bffc-d3043015603f','1d2ed912-1429-4045-a608-35b8db63f9b4','45ff272c-be6d-4faa-a932-a5285a19afe6','d6128ed8-e209-43db-88eb-e9d0115ba8d7','1ec3f726-b495-423b-a5dc-60f5178fdf21','0f2b3400-05d4-40db-b8fa-41c92ab877eb','48150d71-bf95-49b0-924f-e9bcff63fc03','fd6f2a49-1057-4f30-aab4-7cd185764f7e','aea8bb8f-de02-41ad-bd1f-7616780dde30','91dacfed-46ae-462d-aae3-5e540fe4a973'
|
||||||
|
]);
|
||||||
|
const excludedId = '8d49681a-4b27-445e-9fda-915a3a7d7f5c';
|
||||||
|
const existingCategoryIds = new Set(['c1000000-0000-0000-0000-000000000002','fcb36b61-6081-4bf6-9267-1ba9ba75fc08','43106c46-042a-4032-a663-8e8206273927','ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da','ecabc509-9fed-48cb-bd60-c5d830d2491d']);
|
||||||
|
const taxonomy = {
|
||||||
|
'flirty-communication': ['Flirting & Anticipation', 'Flirty Communication'], anticipation: ['Flirting & Anticipation', 'Anticipation'],
|
||||||
|
'clothes-confidence': ['Attraction & Confidence', 'Clothes Confidence'], 'private-performance': ['Attraction & Confidence', 'Private Performance'], 'giving-receiving-attention': ['Attraction & Confidence', 'Giving Receiving Attention'],
|
||||||
|
'sensory-cues': ['Sensory & Atmosphere', 'Sensory Cues'], 'settings-atmosphere': ['Sensory & Atmosphere', 'Settings Atmosphere'],
|
||||||
|
'fantasy-exchange': ['Fantasy & Roleplay', 'Fantasy Exchange'], roleplay: ['Fantasy & Roleplay', 'Roleplay'],
|
||||||
|
'taking-the-lead': ['Connection Dynamics', 'Taking The Lead'], following: ['Connection Dynamics', 'Following'], 'shared-control': ['Connection Dynamics', 'Shared Control'],
|
||||||
|
'boundaries-check-ins': ['Trust & Aftercare', 'Boundaries Check Ins'], 'aftercare-reconnection': ['Trust & Aftercare', 'Aftercare Reconnection'],
|
||||||
|
'surprise-novelty': ['Surprise & Play', 'Surprise Novelty'], 'long-distance-desire': ['Long Distance', 'Long Distance Desire']
|
||||||
|
};
|
||||||
|
const newPacks = Object.keys(taxonomy);
|
||||||
|
const typeMatrix = { swipe:[24,50,30], text_answer:[30,75,36], audio:[24,55,33], photo:[26,40,37], who_likely:[10,25,14] };
|
||||||
|
const titles = {
|
||||||
|
'flirty-communication':'Flirty Communication', anticipation:'Anticipation', 'clothes-confidence':'Clothes Confidence', 'private-performance':'Private Performance', 'giving-receiving-attention':'Giving Receiving Attention',
|
||||||
|
'sensory-cues':'Sensory Cues', 'settings-atmosphere':'Settings Atmosphere', 'fantasy-exchange':'Fantasy Exchange', roleplay:'Roleplay', 'taking-the-lead':'Taking The Lead', following:'Following', 'shared-control':'Shared Control', 'boundaries-check-ins':'Boundaries Check Ins', 'aftercare-reconnection':'Aftercare Reconnection', 'surprise-novelty':'Surprise Novelty', 'long-distance-desire':'Long Distance Desire'
|
||||||
|
};
|
||||||
|
function uuid(seed) { const h=createHash('sha256').update(seed).digest('hex'); return `${h.slice(0,8)}-${h.slice(8,12)}-4${h.slice(13,16)}-8${h.slice(17,20)}-${h.slice(20,32)}`; }
|
||||||
|
function norm(s='') { return s.toLowerCase().replace(/[^a-z0-9 ]/g,' ').replace(/\b(your|partner|the|a|an|to|and|with|for|of|in|on)\b/g,' ').replace(/\s+/g,' ').trim(); }
|
||||||
|
function getExisting() {
|
||||||
|
const snapshot=JSON.parse(readFileSync(snapshotPath)); const t=snapshot.tables;
|
||||||
|
const packs=new Map(t.question_packs.map(x=>[x.id,x])); const cats=new Map(t.categories.map(x=>[x.id,x]));
|
||||||
|
return t.questions.filter(q=>!q.deleted_at && q.id!==excludedId && allowPacks.has(q.pack_id)).map(q=>({...q,status:'EXISTING',pack_slug:packs.get(q.pack_id).name,pack_name:packs.get(q.pack_id).name,category_name:cats.get(packs.get(q.pack_id).category_id)?.name ?? 'Uncategorised'}));
|
||||||
|
}
|
||||||
|
const ideas = {
|
||||||
|
'flirty-communication':['a compliment that would stay with you','a line that makes you feel chosen','a small invitation you would enjoy receiving','a playful message that would make you smile','a secret phrase that signals interest','a kind of praise that feels personal','a question that opens a private conversation','a detail you want your partner to notice'],
|
||||||
|
anticipation:['the part of a date you most enjoy looking forward to','a small ritual that creates build-up','a plan that makes waiting feel exciting','a clue you would enjoy being given','a way to make an ordinary evening feel charged','a surprise you would like hinted at','a moment worth stretching out','a private promise that would make you curious'],
|
||||||
|
'clothes-confidence':['an outfit detail that changes your posture','a colour that makes you feel magnetic','a look you want your partner to notice','an accessory that feels like your signature','a style you would like to try for them','a look that makes you feel bold','a detail that makes getting ready fun','an outfit that deserves a second glance'],
|
||||||
|
'private-performance':['a gesture that makes you feel desired','a look that makes you feel seen','a small performance that feels playful','a way your partner could hold your attention','a private entrance that would delight you','a moment where confidence is attractive','a way to make eye contact feel meaningful','a private flourish you would enjoy'],
|
||||||
|
'giving-receiving-attention':['a kind of attention that helps you relax','a detail that makes you feel appreciated','a way you like being looked after','a way you enjoy making your partner feel special','a moment of focus that feels generous','a signal that you want more closeness','a gesture that says you are fully present','a way to make affection feel intentional'],
|
||||||
|
'sensory-cues':['a scent that changes the mood','a sound that makes a room feel private','a texture that feels comforting','a song that creates anticipation','a drink that feels celebratory','a lighting choice that changes the atmosphere','a shared snack that feels indulgent','a detail that wakes up your senses'],
|
||||||
|
'settings-atmosphere':['a corner of home that could feel more special','a setting that makes conversation easier','a room detail that changes the mood','a place that feels like an escape','a simple way to make privacy feel intentional','a space that invites closeness','a scene you would like to create together','an atmosphere that makes you linger'],
|
||||||
|
'fantasy-exchange':['a version of a date with no interruptions','a mood you have been curious to explore','a private scenario that feels exciting in theory','a setting where you would feel more daring','a role you would enjoy imagining','a detail that makes a fantasy feel personal','a playful what-if you want to share','a made-up rule that would make a date more interesting'],
|
||||||
|
roleplay:['a character energy you would enjoy borrowing','a made-up first meeting you could play with','a setting that makes pretending easier','a secret identity you would enjoy inventing','a role that lets you act more confident','a fictional rule you would enjoy following','a scene that could be flirtier with a little acting','a character detail that would make you laugh'],
|
||||||
|
'taking-the-lead':['a decision you would like to make for your partner','a private plan you want to steer','a moment where you would enjoy setting the pace','a surprise you would like to arrange','a choice you want your partner to trust you with','a way you would guide the mood','a detail you would like to take charge of','a signal that you are ready to lead'],
|
||||||
|
following:['a decision you would enjoy handing over','a moment where you want your partner to set the pace','a surprise you would like to receive','a choice you would enjoy letting your partner make','a way you could show willing trust','a plan you would rather discover than arrange','a cue that helps you relax into following','a detail you would enjoy being guided through'],
|
||||||
|
'shared-control':['a decision you want to make together','a rule that would make a date more playful','a way to take turns choosing','a signal for switching the lead','a compromise that could feel exciting','a shared boundary that creates freedom','a plan where each person gets a say','a way to make a mutual choice feel special'],
|
||||||
|
'boundaries-check-ins':['a limit that helps you feel free to explore','a question that makes consent feel natural','a signal for slowing down','a way to ask for a change without awkwardness','a boundary that deserves appreciation','a check-in that makes you feel cared for','a yes that you want to make more specific','a way to leave room for uncertainty'],
|
||||||
|
'aftercare-reconnection':['a word that helps you feel grounded','a small kindness you want after closeness','a way to return to ordinary life together','a detail that makes you feel cared for','a moment of quiet you would enjoy sharing','a question that helps you reconnect','a ritual that says the experience mattered','a way to make space for feelings afterward'],
|
||||||
|
'surprise-novelty':['a new twist that still feels comfortable','a surprise that would make you feel chosen','a different way to begin a date','a small risk that sounds fun','a mystery you would enjoy unwrapping','a routine worth changing','a private challenge that feels playful','a detail that would make a familiar evening fresh'],
|
||||||
|
'long-distance-desire':['a message that bridges the distance','a detail that makes you feel close from apart','a ritual that helps you miss each other well','a voice note you would save','a shared plan for your next reunion','a small object that reminds you of your partner','a way to make a call feel less ordinary','a promise that makes distance feel temporary']
|
||||||
|
};
|
||||||
|
function makeV2() {
|
||||||
|
const rows=[]; let serial=0; const add=(pack,type,intensity,text,partner_text=null,inverse_of=null,extra={})=>rows.push({id:uuid(`v2-${serial++}-${text}`),pack_slug:pack,text,partner_text,intensity,question_type:type,config:type==='audio'?{max_duration_seconds:60}:{},allowed_couple_genders:null,target_user_genders:null,required_props:null,inverse_of,review_status:'draft',policy_risk:intensity===4?'edge':'low',intended_outcome:extra.outcome ?? 'Both partners create a private, specific result that they can revisit together.'});
|
||||||
|
// 32 natural role reversals: first 24 L2, then 8 L3; rows subsequently get allocation altered below.
|
||||||
|
const roles=[['taking-the-lead','Ask your partner to let you choose','Let your partner choose'],['following','Ask your partner to choose','Choose'],['shared-control','Suggest that you each choose one part of','Choose one part of'],['surprise-novelty','Offer to surprise your partner with','Let your partner surprise you with']];
|
||||||
|
for(let i=0;i<32;i++){const [p,a,b]=roles[i%roles.length], idea=ideas[p][Math.floor(i/4)%8]; const t=`${a} ${idea}.`; const pt=`${b} ${idea} for your partner.`; const id=uuid(`v2-${serial}-${t}`); add(p,'swipe',i<12?2:3,t,pt,null,{outcome:'One partner proposes a clear role while the other receives the complementary invitation.'}); rows[rows.length-1].inverse_of=uuid(`v2-${serial}-${pt}`); add(p,'swipe',i<12?2:3,pt,t,id,{outcome:'One partner receives a clear role while the other proposes the complementary invitation.'}); }
|
||||||
|
// 40 mutual proposals with specific activities.
|
||||||
|
for(let i=0;i<40;i++){const p=newPacks[i%newPacks.length], idea=ideas[p][Math.floor(i/16)%8]; add(p,'swipe',i<12?2:i<32?3:4,`Would you both like to make room for ${idea}?`,null,null,{outcome:'Both partners can agree to try one precise shared idea.'});}
|
||||||
|
const patterns={
|
||||||
|
text_answer:(idea,p)=>`Write the exact words you would use to tell your partner about ${idea}.`,
|
||||||
|
audio:(idea,p)=>`Record a voice note describing ${idea}, using the tone you want your partner to hear.`,
|
||||||
|
photo:(idea,p)=>`Share a photo of a safe, non-personal detail that represents ${idea}.`,
|
||||||
|
who_likely:(idea,p)=>`Who is more likely to turn ${idea} into a shared plan?`
|
||||||
|
};
|
||||||
|
for(const [type,nums] of Object.entries(typeMatrix)){
|
||||||
|
if(type==='swipe') continue;
|
||||||
|
const count=nums.reduce((a,b)=>a+b,0);
|
||||||
|
for(let i=0;i<count;i++){const intensity=i<nums[0]?2:i<nums[0]+nums[1]?3:4; const p=newPacks[(i*3+(type==='audio'?2:type==='photo'?5:type==='who_likely'?7:0))%newPacks.length]; const idea=ideas[p][Math.floor(i/newPacks.length)%8]; let text=patterns[type](idea,p);
|
||||||
|
if(type==='photo') { const safe=['a colour, fabric, object, note, lighting choice, or room detail','an outfit accessory, place setting, song screen, or handwritten note','a safe scene detail such as a lamp, book, drink, or mood board'][i%3]; text=`Share a photo of ${safe} that represents ${idea}.`; }
|
||||||
|
if(type==='text_answer') { const approaches=['Write a short invitation about','Write a private note about','Finish the sentence, "I would love more of", with','Write a kind request connected to','Describe in your own words']; text=`${approaches[i%5]} ${idea}.`; }
|
||||||
|
if(type==='audio') { const cues=['using a warm tone','with a playful pause','as if you were speaking close by','with the confidence you want to borrow','slowly enough for your partner to picture it']; text=`Record a voice note about ${idea}, ${cues[i%5]}.`; }
|
||||||
|
if(type==='who_likely') { const endings=['into a shared plan','into a thoughtful surprise','into a date detail','into a private ritual','into a brave conversation']; text=`Who is more likely to turn ${idea} ${endings[i%5]}?`; }
|
||||||
|
add(p,type,intensity,text,null,null,{outcome:type==='audio'?'Each partner leaves a distinct spoken message with voice and tone.':type==='photo'?'Each partner shares a safe visual cue that can start a private conversation.':type==='who_likely'?'Both partners make a comparative choice that opens a light follow-up.':'Each partner provides a private, personally worded detail.'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Correct swipe intensity exact 24/50/30 without affecting natural copy.
|
||||||
|
const sw=rows.filter(r=>r.question_type==='swipe'); sw.forEach((r,i)=>r.intensity=i<24?2:i<74?3:4);
|
||||||
|
return {version:'2.0',title:'Play-safe spicy extension v2',status:'source-only-unpublished',questions:rows};
|
||||||
|
}
|
||||||
|
function packMeta(snapshot) { const t=snapshot.tables,cats=new Map(t.categories.map(c=>[c.id,c])),packs=new Map(t.question_packs.map(p=>[p.id,p])); return {cats,packs}; }
|
||||||
|
function displayRow(q) { const p=q.partner_text ? `\n - Partner text: ${q.partner_text}` : ''; const inv=q.inverse_of ? `\n - Inverse: ${q.inverse_of}` : ''; return `- **${q.status}** | Type: \`${q.question_type}\` | Intensity: ${q.intensity} | ID: \`${q.id}\`\n - Text: ${q.text}${p}${inv}`; }
|
||||||
|
function markdown(existing,v1,v2,snapshot) {
|
||||||
|
const all=[...existing,...v1.map(q=>({...q,status:'NEW'})),...v2.map(q=>({...q,status:'NEW'}))]; const {cats,packs}=packMeta(snapshot);
|
||||||
|
const grouped=new Map();
|
||||||
|
for(const q of all){ let category,pack,status=q.status; if(status==='EXISTING'){const p=packs.get(q.pack_id);category=cats.get(p.category_id).name;pack=p.name;} else {[category,pack]=taxonomy[q.pack_slug];} const key=`${category}\u0000${pack}\u0000${status}`; if(!grouped.has(key))grouped.set(key,[]); grouped.get(key).push(q); }
|
||||||
|
const lines=['# Full live catalogue, 1,650 rows','', 'Status: source-only planning document. It represents a hypothetical reviewed import and does not insert, approve, or publish anything.','', '## Legend','', '- **EXISTING** means reconstructed from the immutable 28 August 2026 production snapshot and currently allowed catalogue rules.','- **NEW** means a proposed source-only row.','- Type is the existing app interaction type. Partner text appears only for asymmetric swipe rows.','', '## Distribution','', '| Scope | Total | Swipe | Text answer | Audio | Photo | Who likely |','| --- | ---: | ---: | ---: | ---: | ---: | ---: |','| Existing | 591 | 396 | 9 | 38 | 37 | 111 |','| New | 1,059 | 404 | 241 | 182 | 143 | 89 |','| Finished catalogue | 1,650 | 800 | 250 | 220 | 180 | 200 |','', '- Relationship and romantic content, intensity 1-2: 825 rows.','- Adult-leaning private content, intensity 3-4: 825 rows.','', '## Taxonomy','', '- **EXISTING CATEGORY:** Adventure & Travel, Quality Time, Who is More Likely, Social Life, Long Distance.','- **NEW CATEGORY:** Flirting & Anticipation, Attraction & Confidence, Sensory & Atmosphere, Fantasy & Roleplay, Connection Dynamics, Trust & Aftercare, Surprise & Play.',''];
|
||||||
|
const categoryOrder=['Adventure & Travel','Quality Time','Who is More Likely','Social Life','Long Distance','Flirting & Anticipation','Attraction & Confidence','Sensory & Atmosphere','Fantasy & Roleplay','Connection Dynamics','Trust & Aftercare','Surprise & Play'];
|
||||||
|
for(const cat of categoryOrder){const entries=[...grouped.entries()].filter(([k])=>k.split('\u0000')[0]===cat); if(!entries.length)continue; const isNew=entries.some(([k])=>k.split('\u0000')[2]==='NEW'); const isExisting=entries.some(([k])=>k.split('\u0000')[2]==='EXISTING'); lines.push(`## ${isNew&&!isExisting?'NEW CATEGORY':isExisting&&!isNew?'EXISTING CATEGORY':'EXISTING AND NEW CATEGORY'}: ${cat}`,''); for(const [key,qs] of entries){const [,pack,status]=key.split('\u0000'); lines.push(`### ${status} PACK: ${pack}`, ''); qs.sort((a,b)=>a.id.localeCompare(b.id)).forEach(q=>lines.push(displayRow(q))); lines.push('');}}
|
||||||
|
while(lines.at(-1)==='') lines.pop(); return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
function count(rows,key){return Object.fromEntries([...new Set(rows.map(r=>r[key]))].sort().map(x=>[x,rows.filter(r=>r[key]===x).length]));}
|
||||||
|
function sameCounts(actual, expected){return Object.keys(expected).every(k=>actual[k]===expected[k])&&Object.keys(actual).length===Object.keys(expected).length;}
|
||||||
|
function fail(message){throw new Error(message)}
|
||||||
|
function validate() {
|
||||||
|
// Git may check this immutable JSON out with CRLF on Windows. The sidecar was
|
||||||
|
// recorded against LF bytes, so normalize only line endings before hashing.
|
||||||
|
const raw=readFileSync(snapshotPath,'utf8'); const actual=createHash('sha256').update(raw.replace(/\r\n/g,'\n')).digest('hex'); const expected=readFileSync(checksumPath,'utf8').trim().split(/\s+/)[0]; if(actual!==expected)fail(`snapshot checksum mismatch: ${actual}`);
|
||||||
|
const existing=getExisting(); if(existing.length!==591)fail(`existing expected 591, got ${existing.length}`);
|
||||||
|
const v1=JSON.parse(readFileSync(v1Path)).questions; const v2=JSON.parse(readFileSync(v2Path)).questions; const v2Ids=new Set(v2.map(q=>q.id)); if(v1.length!==550||v2.length!==509)fail('new source count mismatch'); const all=[...existing,...v1,...v2]; if(all.length!==1650)fail('total mismatch');
|
||||||
|
for(const [type, levels] of Object.entries(typeMatrix)){for(const [offset, intensity] of [2,3,4].entries()){const actual=v2.filter(q=>q.question_type===type&&q.intensity===intensity).length;if(actual!==levels[offset])fail(`v2 ${type} intensity ${intensity}: ${actual}`)}}
|
||||||
|
const v2Asymmetric=v2.filter(q=>q.question_type==='swipe'&&q.partner_text); if(v2Asymmetric.length!==64)fail(`v2 asymmetric swipe rows: ${v2Asymmetric.length}`); if(v2.filter(q=>q.question_type==='swipe'&&!q.partner_text).length!==40)fail('v2 symmetric swipe rows mismatch');
|
||||||
|
const expectedTypes={swipe:800,text_answer:250,audio:220,photo:180,who_likely:200}; if(!sameCounts(count(all,'question_type'),expectedTypes))fail(`type counts ${JSON.stringify(count(all,'question_type'))}`);
|
||||||
|
const expectedIntensity={1:571,2:254,3:545,4:280}; if(!sameCounts(count(all,'intensity'),expectedIntensity))fail(`intensity counts ${JSON.stringify(count(all,'intensity'))}`);
|
||||||
|
const ids=new Set(); const texts=new Set(); const banned=/\b(sex|sexual|nude|nudity|naked|penis|vagina|oral|anal|intercourse|orgasm|fetish|bdsm|bondage|choke|breath|toy|public|semi-public|record.*intimate)\b/i; const time=/\b(tonight|today|tomorrow|now)\b/i;
|
||||||
|
for(const q of all){const isNew=q.status!=='EXISTING';const isV2=v2Ids.has(q.id);if(ids.has(q.id))fail(`duplicate ID ${q.id}`);ids.add(q.id);if(isV2&&(banned.test(q.text)||time.test(q.text)||/—/.test(q.text)))fail(`unsafe wording ${q.id}`); const n=norm(q.text); if(isNew&&texts.has(n))fail(`duplicate normalized text ${q.text}`);texts.add(n); if(isV2&&q.question_type==='audio'&&!/(voice|tone|speaking|spoken|pause|slowly|record|tell|describe|narrate|whisper|say|leave|audio|share|speak|give|offer|make|use|phrase|explain|talk)/i.test(q.text))fail(`audio lacks voice cue ${q.id}`); if(isV2&&q.question_type==='photo'&&/\\b(body|skin|proof|intimate|bed|selfie)\\b/i.test(q.text))fail(`unsafe photo ${q.id}`); if(isV2&&q.question_type==='who_likely'&&!/^(Who|Which partner)/i.test(q.text))fail(`who form ${q.id}`); if(q.question_type!=='swipe'&&(q.partner_text||q.inverse_of))fail(`non-swipe metadata ${q.id}`);}
|
||||||
|
const byId=new Map(all.map(q=>[q.id,q])); for(const q of v2.filter(q=>q.question_type==='swipe'&&q.partner_text)){const other=[...v2].find(candidate=>candidate.question_type==='swipe'&&candidate.text===q.partner_text&&candidate.partner_text===q.text);if(!other||!(q.inverse_of===other.id||other.inverse_of===q.id))fail(`reciprocal swipe integrity ${q.id}`)}
|
||||||
|
const md=readFileSync(markdownPath,'utf8'); for(const q of all){if(!md.includes(`ID: \`${q.id}\``)||!md.includes(`**${q.status??'NEW'}**`))fail(`markdown missing ${q.id}`)}
|
||||||
|
console.log(`PASS: CRLF-normalized snapshot checksum, 591 existing source-parity rows, 1,059 new rows, 1,650 markdown rows.`); console.log(`Types: ${JSON.stringify(expectedTypes)}. Intensities: ${JSON.stringify(expectedIntensity)}. Extension matrix and 32 inverse concepts pass.`);
|
||||||
|
}
|
||||||
|
if(process.argv.includes('--write')) { const v2=JSON.parse(readFileSync(v2Path)); const snapshot=JSON.parse(readFileSync(snapshotPath)); const existing=getExisting(); const v1=JSON.parse(readFileSync(v1Path)).questions; writeFileSync(markdownPath,markdown(existing,v1,v2.questions,snapshot)); }
|
||||||
|
if(!existsSync(v2Path)||!existsSync(markdownPath)) { console.error('Run with --write to create the source-only extension and catalogue.'); process.exit(1); }
|
||||||
|
validate();
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
const root = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
const base = path.join(root, '.agents', 'learnings');
|
const base = path.join(root, '.agents', 'learnings');
|
||||||
const requiredSections = ['## Symptom', '## Reproduction', '## Cause', '## Remediation', '## Evidence'];
|
const requiredSections = ['## Symptom', '## Reproduction', '## Cause', '## Remediation', '## Evidence'];
|
||||||
const failures = [];
|
const failures = [];
|
||||||
@@ -11,7 +12,7 @@ for (const state of ['inbox', 'promoted']) {
|
|||||||
for (const name of await readdir(directory)) {
|
for (const name of await readdir(directory)) {
|
||||||
if (!name.endsWith('.md')) continue;
|
if (!name.endsWith('.md')) continue;
|
||||||
const file = path.join(directory, name);
|
const file = path.join(directory, name);
|
||||||
const source = await readFile(file, 'utf8');
|
const source = (await readFile(file, 'utf8')).replace(/\r\n/g, '\n');
|
||||||
const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
|
const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
|
||||||
if (!frontmatter) { failures.push(`${file}: missing YAML frontmatter`); continue; }
|
if (!frontmatter) { failures.push(`${file}: missing YAML frontmatter`); continue; }
|
||||||
const fields = Object.fromEntries(frontmatter[1].split('\n').map((line) => {
|
const fields = Object.fromEntries(frontmatter[1].split('\n').map((line) => {
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const draftPath = new URL('../docs/product/question-drafts/play-safe-spicy-v1.json', import.meta.url);
|
||||||
|
const snapshotPath = new URL('../apps/supabase/catalog-snapshots/production-2026-08-28.json', import.meta.url);
|
||||||
|
const markdownPath = new URL('../docs/product/question-drafts/play-safe-spicy-v1.md', import.meta.url);
|
||||||
|
const textSourcePath = new URL('../docs/product/question-drafts/text-answer-concept-source.md', import.meta.url);
|
||||||
|
const audioSourcePath = new URL('../docs/product/question-drafts/audio-concept-source.md', import.meta.url);
|
||||||
|
const draft = JSON.parse(await readFile(draftPath, 'utf8'));
|
||||||
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
|
const markdown = await readFile(markdownPath, 'utf8');
|
||||||
|
const textSource = await readFile(textSourcePath, 'utf8');
|
||||||
|
const audioSource = await readFile(audioSourcePath, 'utf8');
|
||||||
|
const errors = [];
|
||||||
|
const expect = (condition, message) => { if (!condition) errors.push(message); };
|
||||||
|
const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||||
|
const questionTypes = new Set(['swipe', 'text_answer', 'audio', 'photo', 'who_likely']);
|
||||||
|
const banned = /\b(?:anal|anus|asshole|bdsm|bestiality|blowjob|breath play|chok(?:e|ing)|cock|cum|dick|dildo|domination|fetish|fingering|handjob|incest|masturbat|naked|nud(?:e|ity)|oral sex|orgasm|penis|porn|public sex|sex toy|spank(?:ing)?|threesome|vagina|vibrator)\b/i;
|
||||||
|
const timeBound = /\b(?:today|tonight|right now|\bnow\b)\b/i;
|
||||||
|
const gendered = /\b(?:boyfriend|girlfriend|husband|wife|him|her|his|hers)\b/i;
|
||||||
|
const photoUnsafe = /\b(?:body part|lingerie|nude|naked|proof|skin|undress|your body)\b/i;
|
||||||
|
const artificialSuffix = /\b(?:option|response focus|voice focus|visual cue|variant|version)\s*\d+\b/i;
|
||||||
|
const malformedOrFallback = /\b(?:let your partner to|as they for|share this private idea|take the complementary role|response focus|voice focus|bolder mood|confident outfit|unfamiliar private dynamic)\b/i;
|
||||||
|
const genericOutcome = /\b(?:this match reveals whether|specific written starting point|take the complementary role|gains a personal cue for a close conversation)\b/i;
|
||||||
|
const incompleteSwipe = /^(?:a |an |the |trying |swapping )/i;
|
||||||
|
const nonActionableSwipe = /^(?:close|private|shared|more|something|anything|it|that)\b/i;
|
||||||
|
const markdownPackNames = {
|
||||||
|
'taking-the-lead': 'Taking the Lead',
|
||||||
|
'clothes-confidence': 'Clothes and Confidence',
|
||||||
|
'settings-atmosphere': 'Settings and Atmosphere',
|
||||||
|
'boundaries-check-ins': 'Boundaries and Check-ins',
|
||||||
|
'surprise-novelty': 'Surprise and Novelty',
|
||||||
|
'long-distance-desire': 'Long-distance Desire',
|
||||||
|
'giving-receiving-attention': 'Giving and Receiving Attention',
|
||||||
|
'aftercare-reconnection': 'Aftercare and Reconnection',
|
||||||
|
};
|
||||||
|
const sourcePackSlugs = {
|
||||||
|
'flirty communication': 'flirty-communication',
|
||||||
|
anticipation: 'anticipation',
|
||||||
|
'taking the lead': 'taking-the-lead',
|
||||||
|
following: 'following',
|
||||||
|
'shared control': 'shared-control',
|
||||||
|
'sensory cues': 'sensory-cues',
|
||||||
|
'private performance': 'private-performance',
|
||||||
|
'clothes and confidence': 'clothes-confidence',
|
||||||
|
'fantasy exchange': 'fantasy-exchange',
|
||||||
|
roleplay: 'roleplay',
|
||||||
|
'settings and atmosphere': 'settings-atmosphere',
|
||||||
|
'boundaries and check-ins': 'boundaries-check-ins',
|
||||||
|
'surprise and novelty': 'surprise-novelty',
|
||||||
|
'long-distance desire': 'long-distance-desire',
|
||||||
|
'giving and receiving attention': 'giving-receiving-attention',
|
||||||
|
'aftercare and reconnection': 'aftercare-reconnection',
|
||||||
|
};
|
||||||
|
const parseSource = (source, pattern, type) => [...source.matchAll(pattern)].map((match) => ({
|
||||||
|
pack_slug: sourcePackSlugs[match[1].toLowerCase()],
|
||||||
|
intensity: Number(match[2]),
|
||||||
|
text: match[3],
|
||||||
|
intended_outcome: match[4],
|
||||||
|
question_type: type,
|
||||||
|
}));
|
||||||
|
const textSourceRows = parseSource(textSource, /^\d+\. \*\*Theme:\*\* (.+?) \| \*\*Intensity:\*\* (\d)\s*\r?\n\s*\*\*Prompt:\*\* (.+?)\s*\r?\n\s*\*\*Intended outcome:\*\* (.+?)$/gm, 'text_answer');
|
||||||
|
const audioSourceRows = parseSource(audioSource, /^\d+\. Theme: (.+?) \| Intensity: (\d)\s*\r?\n\s*Prompt: (.+?)\s*\r?\n\s*Intended outcome: (.+?)$/gm, 'audio');
|
||||||
|
expect(textSourceRows.length === 100, `expected 100 text source rows, got ${textSourceRows.length}`);
|
||||||
|
expect(audioSourceRows.length === 70, `expected 70 audio source rows, got ${audioSourceRows.length}`);
|
||||||
|
const sourceKey = (row) => JSON.stringify([row.pack_slug, row.intensity, row.text, row.intended_outcome]);
|
||||||
|
|
||||||
|
expect(Array.isArray(draft.packs) && Array.isArray(draft.questions), 'draft must contain packs and questions arrays');
|
||||||
|
const questions = draft.questions ?? [];
|
||||||
|
expect(questions.length === 550, `expected 550 questions, got ${questions.length}`);
|
||||||
|
expect((draft.packs ?? []).length > 0, 'draft must contain at least one pack');
|
||||||
|
const ids = new Set();
|
||||||
|
const byId = new Map();
|
||||||
|
const typeCounts = Object.fromEntries([...questionTypes].map((type) => [type, 0]));
|
||||||
|
const intensityCounts = Object.fromEntries([1, 2, 3, 4, 5].map((level) => [level, 0]));
|
||||||
|
const packCounts = new Map();
|
||||||
|
const draftTexts = new Map();
|
||||||
|
const openingCounts = new Map();
|
||||||
|
const ngramRows = new Map();
|
||||||
|
const outcomeClusters = new Map();
|
||||||
|
const responseLeadingVerbs = new Map();
|
||||||
|
const requiredFormatOpenings = new Set(['share a photo of', 'who is more likely']);
|
||||||
|
const snapshotTexts = new Set((snapshot.tables?.questions ?? []).map((row) => normalize(row.text)));
|
||||||
|
for (const [index, question] of questions.entries()) {
|
||||||
|
const label = `question ${index + 1}`;
|
||||||
|
expect(typeof question.id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(question.id), `${label} has invalid UUID`);
|
||||||
|
expect(!ids.has(question.id), `${label} duplicates UUID ${question.id}`);
|
||||||
|
ids.add(question.id); byId.set(question.id, question);
|
||||||
|
expect(typeof question.pack_slug === 'string' && question.pack_slug.length > 0, `${label} has no pack_slug`);
|
||||||
|
packCounts.set(question.pack_slug, (packCounts.get(question.pack_slug) ?? 0) + 1);
|
||||||
|
expect(typeof question.text === 'string' && question.text.length > 0, `${label} has no text`);
|
||||||
|
expect(!artificialSuffix.test(question.text), `${label} contains an artificial template discriminator`);
|
||||||
|
expect(!malformedOrFallback.test(question.text), `${label} contains malformed or generic fallback copy`);
|
||||||
|
expect(questionTypes.has(question.question_type), `${label} has unsupported type ${question.question_type}`);
|
||||||
|
if (questionTypes.has(question.question_type)) typeCounts[question.question_type] += 1;
|
||||||
|
expect([2, 3, 4].includes(question.intensity), `${label} has invalid intensity ${question.intensity}`);
|
||||||
|
if (Object.hasOwn(intensityCounts, question.intensity)) intensityCounts[question.intensity] += 1;
|
||||||
|
expect(question.review_status === 'draft', `${label} must be draft`);
|
||||||
|
expect(['low', 'edge'].includes(question.policy_risk), `${label} has invalid policy_risk`);
|
||||||
|
expect(typeof question.intended_outcome === 'string' && question.intended_outcome.length > 0, `${label} has no intended_outcome`);
|
||||||
|
expect(!genericOutcome.test(question.intended_outcome), `${label} has a generic fallback outcome`);
|
||||||
|
expect(question.allowed_couple_genders === null && question.target_user_genders === null && question.required_props === null, `${label} must use null targeting and props`);
|
||||||
|
const textFields = [question.text, question.partner_text, question.intended_outcome].filter(Boolean);
|
||||||
|
for (const text of textFields) {
|
||||||
|
expect(!banned.test(text), `${label} contains banned developer-authored term`);
|
||||||
|
expect(!timeBound.test(text), `${label} contains time-bound language`);
|
||||||
|
expect(!gendered.test(text), `${label} contains gendered partner language`);
|
||||||
|
expect(!text.includes('\u2014'), `${label} contains an em dash`);
|
||||||
|
}
|
||||||
|
const normalized = normalize(question.text);
|
||||||
|
if (question.question_type === 'swipe') {
|
||||||
|
expect(!incompleteSwipe.test(question.text) && !nonActionableSwipe.test(question.text), `${label} swipe text must be a complete actionable proposal`);
|
||||||
|
if (question.partner_text !== null) {
|
||||||
|
expect(question.text !== question.partner_text, `${label} asymmetric swipe must use distinct partner wording`);
|
||||||
|
expect(!incompleteSwipe.test(question.partner_text) && !nonActionableSwipe.test(question.partner_text), `${label} asymmetric partner_text must be a complete actionable proposal`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (question.question_type === 'text_answer' || question.question_type === 'audio') {
|
||||||
|
const leadingVerb = normalized.split(' ')[0];
|
||||||
|
const key = `${question.question_type}:${leadingVerb}`;
|
||||||
|
responseLeadingVerbs.set(key, (responseLeadingVerbs.get(key) ?? 0) + 1);
|
||||||
|
const outcomeCluster = normalize(question.intended_outcome)
|
||||||
|
.replace(/\b(?:your|partner|the|couple|one|a|an|and|or|can|to|with|for|of|that|this|their|them|you)\b/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const rows = outcomeClusters.get(outcomeCluster) ?? [];
|
||||||
|
rows.push(label);
|
||||||
|
outcomeClusters.set(outcomeCluster, rows);
|
||||||
|
}
|
||||||
|
const opening = normalized.split(' ').slice(0, 4).join(' ');
|
||||||
|
if (!requiredFormatOpenings.has(opening)) {
|
||||||
|
openingCounts.set(opening, (openingCounts.get(opening) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
// An inverse row intentionally repeats its primary row's partner wording.
|
||||||
|
// Check only primary and symmetric text here; inverse swap integrity is
|
||||||
|
// checked below.
|
||||||
|
if (question.inverse_of === null) {
|
||||||
|
expect(!draftTexts.has(normalized), `${label} duplicates draft text from ${draftTexts.get(normalized)}`);
|
||||||
|
draftTexts.set(normalized, label);
|
||||||
|
const words = normalized.split(' ').filter(Boolean);
|
||||||
|
for (let start = 0; start <= words.length - 6; start += 1) {
|
||||||
|
const ngram = words.slice(start, start + 6).join(' ');
|
||||||
|
const rows = ngramRows.get(ngram) ?? [];
|
||||||
|
rows.push(label);
|
||||||
|
ngramRows.set(ngram, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(!snapshotTexts.has(normalized), `${label} duplicates production snapshot text`);
|
||||||
|
if (question.question_type === 'audio') expect(JSON.stringify(question.config) === JSON.stringify({ max_duration_seconds: 60 }), `${label} audio config must be max_duration_seconds 60`);
|
||||||
|
if (question.question_type === 'text_answer') expect(JSON.stringify(question.config) === JSON.stringify({ max_length: 500 }), `${label} text_answer config must be max_length 500`);
|
||||||
|
if (['swipe', 'photo', 'who_likely'].includes(question.question_type)) expect(JSON.stringify(question.config) === '{}', `${label} ${question.question_type} config must be empty`);
|
||||||
|
if (question.question_type !== 'swipe') {
|
||||||
|
expect(question.partner_text === null && question.inverse_of === null, `${label} non-swipe must have null partner_text and inverse_of`);
|
||||||
|
}
|
||||||
|
if (question.question_type === 'photo') expect(!photoUnsafe.test(question.text), `${label} photo prompt is not safely clothed/object/setting based`);
|
||||||
|
}
|
||||||
|
for (const [opening, count] of openingCounts) {
|
||||||
|
expect(count <= 30, `opening template repeats ${count} times: ${opening}`);
|
||||||
|
}
|
||||||
|
for (const [ngram, rows] of ngramRows) {
|
||||||
|
expect(rows.length <= 10, `six-word phrase repeats ${rows.length} times across non-inverse rows: ${ngram}`);
|
||||||
|
}
|
||||||
|
for (const [type, sourceRows] of [['text_answer', textSourceRows], ['audio', audioSourceRows]]) {
|
||||||
|
const catalogueRows = questions.filter((question) => question.question_type === type);
|
||||||
|
const sourceKeys = new Set(sourceRows.map(sourceKey));
|
||||||
|
expect(sourceKeys.size === sourceRows.length, `${type} source contains duplicate concepts`);
|
||||||
|
expect(catalogueRows.length === sourceRows.length, `${type} source and catalogue counts differ`);
|
||||||
|
for (const question of catalogueRows) {
|
||||||
|
expect(sourceKeys.has(sourceKey(question)), `${type} row ${question.id} differs from the authored source`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, count] of responseLeadingVerbs) {
|
||||||
|
const [type, leadingVerb] = key.split(':');
|
||||||
|
const total = type === 'text_answer' ? 100 : 70;
|
||||||
|
expect(count <= Math.ceil(total * 0.6), `${type} leading verb "${leadingVerb}" repeats ${count} times`);
|
||||||
|
}
|
||||||
|
for (const [cluster, rows] of outcomeClusters) {
|
||||||
|
expect(cluster.length >= 16, `${rows.join(', ')} has an overly generic response outcome`);
|
||||||
|
expect(rows.length === 1, `response outcome cluster repeats across ${rows.join(', ')}`);
|
||||||
|
}
|
||||||
|
for (const [type, count] of Object.entries({ swipe: 300, text_answer: 100, audio: 70, photo: 40, who_likely: 40 })) expect(typeCounts[type] === count, `expected ${count} ${type}, got ${typeCounts[type]}`);
|
||||||
|
for (const [level, count] of Object.entries({ 1: 0, 2: 120, 3: 300, 4: 130, 5: 0 })) expect(intensityCounts[level] === count, `expected ${count} intensity ${level}, got ${intensityCounts[level]}`);
|
||||||
|
const swipes = questions.filter((question) => question.question_type === 'swipe');
|
||||||
|
const asymmetric = swipes.filter((question) => question.partner_text !== null);
|
||||||
|
const symmetric = swipes.filter((question) => question.partner_text === null);
|
||||||
|
expect(asymmetric.length === 240, `expected 240 asymmetric swipe rows, got ${asymmetric.length}`);
|
||||||
|
expect(symmetric.length === 60, `expected 60 symmetric swipe rows, got ${symmetric.length}`);
|
||||||
|
const primaries = asymmetric.filter((question) => question.inverse_of === null);
|
||||||
|
const inverses = asymmetric.filter((question) => question.inverse_of !== null);
|
||||||
|
expect(primaries.length === 120 && inverses.length === 120, `expected 120 primary and 120 inverse swipe rows, got ${primaries.length} and ${inverses.length}`);
|
||||||
|
for (const question of inverses) {
|
||||||
|
const primary = byId.get(question.inverse_of);
|
||||||
|
expect(primary?.question_type === 'swipe' && primary.inverse_of === null, `inverse ${question.id} must reference a primary swipe`);
|
||||||
|
if (primary) {
|
||||||
|
expect(question.text === primary.partner_text && question.partner_text === primary.text, `inverse ${question.id} must exactly swap text and partner_text`);
|
||||||
|
expect(question.intensity === primary.intensity && question.pack_slug === primary.pack_slug, `inverse ${question.id} must keep intensity and pack`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const question of symmetric) expect(question.inverse_of === null, `symmetric swipe ${question.id} must have null inverse_of`);
|
||||||
|
expect([...packCounts.values()].every((count) => count > 0), 'every declared pack must contain questions');
|
||||||
|
for (const pack of draft.packs ?? []) {
|
||||||
|
const expectedLine = `- ${markdownPackNames[pack.slug] ?? pack.name}: ${packCounts.get(pack.slug) ?? 0} questions.`;
|
||||||
|
expect(markdown.includes(expectedLine), `markdown pack count is missing or stale: ${expectedLine}`);
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
process.stderr.write(`Question draft validation failed (${errors.length}):\n${errors.map((error) => `- ${error}`).join('\n')}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(`Question draft valid: ${questions.length} total; types ${JSON.stringify(typeCounts)}; intensities ${JSON.stringify(intensityCounts)}; packs ${JSON.stringify(Object.fromEntries(packCounts))}\n`);
|
||||||
Reference in new issue
Block a user