mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Compare commits
23
Commits
402c027b14
...
0f9c9c2e15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9c9c2e15 | ||
|
|
4a57f31615 | ||
|
|
1c7084c033 | ||
|
|
0dfe4435b0 | ||
|
|
5ac4843095 | ||
|
|
8e48467414 | ||
|
|
1fed348072 | ||
|
|
a1b62c3b47 | ||
|
|
4415dcf29f | ||
|
|
95162d1889 | ||
|
|
dd1a47ecfa | ||
|
|
a02e9d531b | ||
|
|
49a9cd270f | ||
|
|
077f5433e1 | ||
|
|
ade2c42d51 | ||
|
|
daefdc6da9 | ||
|
|
029a90b338 | ||
|
|
bb0c712ede | ||
|
|
1c4ec2b412 | ||
|
|
21006cd041 | ||
|
|
f9481054de | ||
|
|
922066f492 | ||
|
|
ff2cf09170 |
No files matched your search
@@ -33,6 +33,7 @@ export interface CoupleRepository {
|
|||||||
getState(userId: string): Promise<CoupleStateResponse>;
|
getState(userId: string): Promise<CoupleStateResponse>;
|
||||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||||
|
cancelInvite(userId: string): Promise<void>;
|
||||||
cancel(userId: string): Promise<void>;
|
cancel(userId: string): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -268,6 +269,43 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<void> {
|
||||||
|
await transaction(this.pool, async (client) => {
|
||||||
|
// Lock in the same order as join(): the caller's profile first, then the
|
||||||
|
// couple. A concurrent join therefore either completes first and makes
|
||||||
|
// this a safe no-op error, or observes that the invite no longer exists.
|
||||||
|
const profile = await profileForUpdate(client, userId);
|
||||||
|
if (!profile.couple_id) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupleResult = await client.query<{ id: string }>(
|
||||||
|
'select id from couples where id = $1 for update',
|
||||||
|
[profile.couple_id],
|
||||||
|
);
|
||||||
|
if (!coupleResult.rows[0]) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await client.query<{ id: string }>(
|
||||||
|
'select id from profiles where couple_id = $1',
|
||||||
|
[profile.couple_id],
|
||||||
|
);
|
||||||
|
if (members.rows.length > 1) {
|
||||||
|
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
|
||||||
|
}
|
||||||
|
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
|
||||||
|
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point the couple has precisely the authenticated creator as a
|
||||||
|
// member. Removing it cannot delete paired data. Answers made before
|
||||||
|
// invite creation stay sealed; answers made while waiting are coupled
|
||||||
|
// and are deleted with the cancelled invite.
|
||||||
|
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await closeResolvedPool(this.pool, this.ownsPool);
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
|
|||||||
const result = await route(() => service.cancel(c.get('identity').id));
|
const result = await route(() => service.cancel(c.get('identity').id));
|
||||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/couple/invite', async (c) => {
|
||||||
|
const result = await route(() => service.cancelInvite(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -41,4 +41,9 @@ export class CoupleService {
|
|||||||
await this.repository.cancel(userId);
|
await this.repository.cancel(userId);
|
||||||
return { success: true, couple_id: null };
|
return { success: true, couple_id: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
|
||||||
|
await this.repository.cancelInvite(userId);
|
||||||
|
return { success: true, couple_id: null };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
|
|||||||
| 'invalid_invite_code'
|
| 'invalid_invite_code'
|
||||||
| 'couple_full'
|
| 'couple_full'
|
||||||
| 'not_paired'
|
| 'not_paired'
|
||||||
|
| 'invite_already_joined'
|
||||||
| 'invite_code_collision';
|
| 'invite_code_collision';
|
||||||
|
|
||||||
export class CoupleError extends Error {
|
export class CoupleError extends Error {
|
||||||
|
|||||||
@@ -68,6 +68,56 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
|||||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels a waiting invite only for its creator', async () => {
|
||||||
|
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
const pack = 'abababab-abab-4bab-8bab-abababababab';
|
||||||
|
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
|
||||||
|
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
|
||||||
|
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
|
||||||
|
await pool.query(
|
||||||
|
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
|
||||||
|
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
|
||||||
|
[randomUUID(), charlie, sealedQuestion],
|
||||||
|
);
|
||||||
|
await repository.create(charlie, coupleId, 'WAIT2345');
|
||||||
|
await pool.query(
|
||||||
|
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
|
||||||
|
[randomUUID(), charlie, waitingQuestion, coupleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.cancelInvite(charlie);
|
||||||
|
|
||||||
|
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
|
||||||
|
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
|
||||||
|
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
|
||||||
|
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never deletes a joined couple through the invite cancellation path', async () => {
|
||||||
|
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
|
await repository.create(alice, coupleId, 'SAFE2345');
|
||||||
|
await repository.join(bob, 'SAFE2345');
|
||||||
|
|
||||||
|
const before = await pool.query<{ id: string; couple_id: string | null }>(
|
||||||
|
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||||
|
[alice, bob],
|
||||||
|
);
|
||||||
|
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
|
||||||
|
code: 'invite_already_joined',
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await pool.query<{ id: string; couple_id: string | null }>(
|
||||||
|
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||||
|
[alice, bob],
|
||||||
|
);
|
||||||
|
expect(after.rows).toEqual(before.rows);
|
||||||
|
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
||||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
|
|||||||
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
||||||
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
||||||
import { CoupleService } from '../src/domains/couples/service.js';
|
import { CoupleService } from '../src/domains/couples/service.js';
|
||||||
|
import { CoupleError } from '../src/domains/couples/types.js';
|
||||||
|
|
||||||
const identity: AuthIdentity = {
|
const identity: AuthIdentity = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -25,6 +26,7 @@ function setup() {
|
|||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
created_at: '2026-08-27T00:00:00.000Z',
|
created_at: '2026-08-27T00:00:00.000Z',
|
||||||
})),
|
})),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
@@ -80,4 +82,28 @@ describe('couple routes', () => {
|
|||||||
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||||
|
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
|
||||||
|
expect(repository.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not turn a joined invite into relationship deletion', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
|
||||||
|
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
|
||||||
|
});
|
||||||
|
expect(repository.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
|
|||||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||||
create: vi.fn(async () => couple),
|
create: vi.fn(async () => couple),
|
||||||
join: vi.fn(async () => couple),
|
join: vi.fn(async () => couple),
|
||||||
|
cancelInvite: vi.fn(async () => undefined),
|
||||||
cancel: vi.fn(async () => undefined),
|
cancel: vi.fn(async () => undefined),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
|
|||||||
expect(create).toHaveBeenCalledTimes(2);
|
expect(create).toHaveBeenCalledTimes(2);
|
||||||
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the invite-only cancellation operation for a waiting creator', async () => {
|
||||||
|
const repo = repository();
|
||||||
|
const service = new CoupleService(repo);
|
||||||
|
|
||||||
|
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
|
||||||
|
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
|
||||||
|
expect(repo.cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -76,10 +76,11 @@ export default function AppLayout() {
|
|||||||
const segmentStrings = segments as string[];
|
const segmentStrings = segments as string[];
|
||||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||||
const isOnPairing = segmentStrings.includes("pairing");
|
const isOnPairing = segmentStrings.includes("pairing");
|
||||||
|
const isOnPaired = segmentStrings.includes("paired");
|
||||||
const isOnChat = segmentStrings.includes("chat");
|
const isOnChat = segmentStrings.includes("chat");
|
||||||
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
||||||
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
||||||
|
|
||||||
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -367,6 +368,7 @@ export default function AppLayout() {
|
|||||||
href: null, // Hide from tab bar
|
href: null, // Hide from tab bar
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen name="paired" options={{ href: null }} />
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="chat/[id]"
|
name="chat/[id]"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { GradientBackground, GlassButton } from "../../src/components/ui";
|
||||||
|
import { colors, gradients, spacing, typography } from "../../src/theme";
|
||||||
|
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||||
|
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
|
||||||
|
import { Events } from "../../src/lib/analytics";
|
||||||
|
|
||||||
|
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
|
||||||
|
const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+";
|
||||||
|
|
||||||
|
export default function PairedScreen() {
|
||||||
|
const { user, couple, partner } = useAuthStore();
|
||||||
|
const { newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
|
||||||
|
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
|
||||||
|
const heading = useRef<Text>(null);
|
||||||
|
const role = rawRole === "inviter" ? "inviter" : "joiner";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchMatches(true).then((success) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadedSuccessfully(success);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [fetchMatches]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || !loadedSuccessfully || !user || !couple) return;
|
||||||
|
void markPairedUnlockSeen(user.id, couple.id);
|
||||||
|
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
|
||||||
|
|
||||||
|
const partnerName = partner?.name?.split(" ")[0] || "your partner";
|
||||||
|
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
|
||||||
|
const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); };
|
||||||
|
const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); };
|
||||||
|
if (!ready) {
|
||||||
|
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
||||||
|
}
|
||||||
|
if (!loadedSuccessfully) {
|
||||||
|
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||||
|
}
|
||||||
|
return <GradientBackground><View style={styles.container}>
|
||||||
|
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
||||||
|
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
|
||||||
|
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} shared discover{newMatchesCount === 1 ? "y" : "ies"} ready to explore</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See shared discoveries</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing is ready to compare yet. Answer a few questions and shared discoveries will appear here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
|
||||||
|
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
|
||||||
|
</View></GradientBackground>;
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} });
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
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";
|
||||||
@@ -27,26 +28,44 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
|
|||||||
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") {
|
||||||
|
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)") }]);
|
||||||
}
|
}
|
||||||
}, [couple, partner]);
|
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) {
|
} catch (error: any) {
|
||||||
Alert.alert("Error", getPairingError(error));
|
const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined;
|
||||||
|
const code = details?.error?.code;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -176,6 +208,7 @@ export default function PairingScreen() {
|
|||||||
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
|
||||||
@@ -210,25 +243,57 @@ export default function PairingScreen() {
|
|||||||
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();
|
||||||
|
|
||||||
await fetchUser();
|
|
||||||
await fetchCouple();
|
|
||||||
Events.pairingCancelled();
|
|
||||||
} catch (error: any) {
|
|
||||||
Alert.alert("Error", getPairingError(error));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -238,6 +303,35 @@ export default function PairingScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
@@ -265,10 +359,22 @@ 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}>
|
||||||
|
{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 */}
|
{/* Icon */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(200).duration(500)}
|
entering={FadeInDown.delay(200).duration(500)}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -358,6 +463,10 @@ export default function PairingScreen() {
|
|||||||
<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,7 +591,12 @@ 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>
|
||||||
|
)}
|
||||||
|
{joinError && (
|
||||||
|
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
|
||||||
|
{joinError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
@@ -490,7 +605,7 @@ export default function PairingScreen() {
|
|||||||
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>
|
||||||
@@ -723,6 +838,28 @@ const styles = StyleSheet.create({
|
|||||||
...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,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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
|
||||||
@@ -130,13 +131,19 @@ export default function RootLayout() {
|
|||||||
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 { isAuthenticated, user } = useAuthStore.getState();
|
||||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||||
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
if (user?.couple_id) {
|
||||||
|
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
|
||||||
|
if (user.couple_id) {
|
||||||
|
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 } });
|
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +272,7 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<ReducedMotionConfig mode={ReduceMotion.System} />
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
|||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
|
||||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
|
||||||
|
|
||||||
// Check once, after auth resolves, whether an invite code is waiting to be
|
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||||
// applied (e.g. the user tapped a join link before signing in).
|
// applied (e.g. the user tapped a join link before signing in).
|
||||||
@@ -18,16 +17,17 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getPendingInviteCode().then((code) => {
|
if (!user?.id) return;
|
||||||
|
setPendingInviteCheck(null);
|
||||||
|
getPendingInviteCode(user.id).then((code) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHasPendingInvite(!!code);
|
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
|
||||||
setPendingCodeChecked(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
|
||||||
|
|
||||||
// Show loading state while auth is being determined
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -56,7 +56,7 @@ export default function Index() {
|
|||||||
// Wait for the one-time pending invite code check before deciding the
|
// Wait for the one-time pending invite code check before deciding the
|
||||||
// route, so a stashed code from a join link is applied without an extra
|
// route, so a stashed code from a join link is applied without an extra
|
||||||
// trip through the home screen.
|
// trip through the home screen.
|
||||||
if (!pendingCodeChecked) {
|
if (pendingInviteCheck?.userId !== user?.id) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
@@ -64,7 +64,7 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingInvite) {
|
if (pendingInviteCheck?.hasInvite) {
|
||||||
return <Redirect href="/(app)/pairing" />;
|
return <Redirect href="/(app)/pairing" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
|
|||||||
await coupleApi.getState();
|
await coupleApi.getState();
|
||||||
await coupleApi.create();
|
await coupleApi.create();
|
||||||
await coupleApi.join("ABCD2345");
|
await coupleApi.join("ABCD2345");
|
||||||
|
await coupleApi.cancelInvite();
|
||||||
await coupleApi.cancel();
|
await coupleApi.cancel();
|
||||||
|
|
||||||
expect(request.mock.calls).toEqual([
|
expect(request.mock.calls).toEqual([
|
||||||
["/v1/couple"],
|
["/v1/couple"],
|
||||||
["/v1/couple", { method: "POST", body: {} }],
|
["/v1/couple", { method: "POST", body: {} }],
|
||||||
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
||||||
|
["/v1/couple/invite", { method: "DELETE" }],
|
||||||
["/v1/couple", { method: "DELETE" }],
|
["/v1/couple", { method: "DELETE" }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
||||||
|
|
||||||
describe("buildInviteShareMessage", () => {
|
describe("buildInviteShareMessage", () => {
|
||||||
it("leads with a plain invite when there are no sealed answers", () => {
|
it("keeps a recoverable bare code in a separate sentence", () => {
|
||||||
const message = buildInviteShareMessage("ABCD1234", 0);
|
const message = buildInviteShareMessage("AB12CD34", 3);
|
||||||
expect(message).toBe(
|
expect(message).toContain("https://sauci.app/join/AB12CD34");
|
||||||
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
expect(message).toContain("enter code AB12CD34 in Sauci");
|
||||||
);
|
expect(message).toContain("Vote-style questions show shared outcomes");
|
||||||
|
expect(message).toContain("open answers can be visible to us both after we each answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leads with earned value and singular wording for exactly one sealed answer", () => {
|
it("does not claim a sealed outcome before answers exist", () => {
|
||||||
const message = buildInviteShareMessage("ABCD1234", 1);
|
expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
|
||||||
expect(message).toBe(
|
|
||||||
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pluralises for more than one sealed answer", () => {
|
|
||||||
const message = buildInviteShareMessage("ABCD1234", 5);
|
|
||||||
expect(message).toContain("I have already answered 5 questions about us.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
jest.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { clearPairedUnlockSeen, hasSeenPairedUnlock, markPairedUnlockSeen } from "../../lib/pairedUnlockSeen";
|
||||||
|
|
||||||
|
describe("paired unlock state", () => {
|
||||||
|
it("is scoped to both account and couple", async () => {
|
||||||
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue("true");
|
||||||
|
await hasSeenPairedUnlock("user-a", "couple-a");
|
||||||
|
await markPairedUnlockSeen("user-a", "couple-a");
|
||||||
|
await clearPairedUnlockSeen("user-a", "couple-a");
|
||||||
|
expect(AsyncStorage.getItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
|
||||||
|
expect(AsyncStorage.setItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a", "true");
|
||||||
|
expect(AsyncStorage.removeItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PAIRING_PRIVACY_SUMMARY } from "../../lib/pairingPrivacyCopy";
|
||||||
|
|
||||||
|
describe("PAIRING_PRIVACY_SUMMARY", () => {
|
||||||
|
it("explains what each response style reveals before a user answers", () => {
|
||||||
|
expect(PAIRING_PRIVACY_SUMMARY).toContain("Vote-style answers reveal shared outcomes");
|
||||||
|
expect(PAIRING_PRIVACY_SUMMARY).toContain("Text, audio, photo, and who-likely responses are shown after you both answer");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
|
|||||||
await AsyncStorage.clear();
|
await AsyncStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stashes, retrieves, and clears a valid code", async () => {
|
it("stashes, retrieves, and clears a valid code for its owner", async () => {
|
||||||
await stashPendingInviteCode("abcd1234");
|
await stashPendingInviteCode("abcd1234", "user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode("user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when nothing has been stashed", async () => {
|
it("returns null when nothing has been stashed", async () => {
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
||||||
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak a claimed invite to a later account", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
await expect(getPendingInviteCode("user-b")).resolves.toBeNull();
|
||||||
|
|
||||||
|
await clearPendingInviteCode("user-b");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only one concurrently arriving account claim a pre-auth invite", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
getPendingInviteCode("user-a"),
|
||||||
|
getPendingInviteCode("user-b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([first, second].filter(Boolean)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { useMessageStore } from "@/store/messageStore";
|
|||||||
import { usePacksStore } from "@/store/packsStore";
|
import { usePacksStore } from "@/store/packsStore";
|
||||||
import { useSubscriptionStore } from "@/store/subscriptionStore";
|
import { useSubscriptionStore } from "@/store/subscriptionStore";
|
||||||
import type { Profile } from "@/types";
|
import type { Profile } from "@/types";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
id: "me",
|
id: "me",
|
||||||
@@ -259,6 +260,7 @@ describe("authStore", () => {
|
|||||||
|
|
||||||
it("clears sealed answers during an explicit sign-out", async () => {
|
it("clears sealed answers during an explicit sign-out", async () => {
|
||||||
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
||||||
|
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
|
||||||
|
|
||||||
await useAuthStore.getState().signOut();
|
await useAuthStore.getState().signOut();
|
||||||
|
|
||||||
@@ -267,6 +269,7 @@ describe("authStore", () => {
|
|||||||
sealedCount: 0,
|
sealedCount: 0,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
|
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears sealed answers when the auth listener clears the user", () => {
|
it("clears sealed answers when the auth listener clears the user", () => {
|
||||||
@@ -281,6 +284,14 @@ describe("authStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a pending invite when the auth listener clears the user", async () => {
|
||||||
|
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
|
||||||
|
useAuthStore.setState({ user: profile, isAuthenticated: true } as any);
|
||||||
|
useAuthStore.getState().setUser(null);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a pending Apple name scoped to its original subject', () => {
|
it('keeps a pending Apple name scoped to its original subject', () => {
|
||||||
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||||
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
|
|||||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
const inFlight = useMatchStore.getState().fetchMatches(true);
|
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||||
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
release({ matches: [], totalCount: 0 });
|
release({ matches: [], totalCount: 0 });
|
||||||
await inFlight;
|
await inFlight;
|
||||||
|
await overlapping;
|
||||||
|
|
||||||
(apiClient.get as jest.Mock).mockClear();
|
(apiClient.get as jest.Mock).mockClear();
|
||||||
useMatchStore.setState({ hasMore: false });
|
useMatchStore.setState({ hasMore: false });
|
||||||
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
|
|||||||
expect(apiClient.get).not.toHaveBeenCalled();
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes overlapping callers await the same failed refresh', async () => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
(apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; }));
|
||||||
|
|
||||||
|
const first = useMatchStore.getState().fetchMatches(true);
|
||||||
|
const second = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
reject(new Error('network'));
|
||||||
|
await expect(first).resolves.toBe(false);
|
||||||
|
await expect(second).resolves.toBe(false);
|
||||||
|
expect(useMatchStore.getState().error).toBe('Failed to load matches');
|
||||||
|
});
|
||||||
|
|
||||||
it('appends and deduplicates later pages', async () => {
|
it('appends and deduplicates later pages', async () => {
|
||||||
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
||||||
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
|
|||||||
onPress={onInvitePress}
|
onPress={onInvitePress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
testID="swipe-sealed-answers-banner"
|
testID="swipe-sealed-answers-banner"
|
||||||
|
accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
|
||||||
>
|
>
|
||||||
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
||||||
<Text style={styles.text}>
|
<View style={styles.copy}>
|
||||||
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
|
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
|
||||||
</Text>
|
<Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.cta}>Invite</Text>
|
<Text style={styles.cta}>Invite</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginHorizontal: spacing.md,
|
marginHorizontal: spacing.md,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.md,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.lg,
|
||||||
backgroundColor: colors.backgroundLight,
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
},
|
},
|
||||||
|
copy: { flex: 1, gap: 2 },
|
||||||
|
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
|
||||||
text: {
|
text: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
|
|||||||
@@ -281,6 +281,18 @@ export const Events = {
|
|||||||
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||||
logEvent("invite_link_opened", { source }),
|
logEvent("invite_link_opened", { source }),
|
||||||
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
||||||
|
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
|
||||||
|
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
|
||||||
|
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
|
||||||
|
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
|
||||||
|
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
|
||||||
|
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
|
||||||
|
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
|
||||||
|
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
|
||||||
|
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
|
||||||
|
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
|
||||||
|
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
|
||||||
|
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
|
||||||
|
|
||||||
// Quiz events
|
// Quiz events
|
||||||
quizStarted: () => logEvent("quiz_started"),
|
quizStarted: () => logEvent("quiz_started"),
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const coupleApi = {
|
|||||||
body: { invite_code: inviteCode },
|
body: { invite_code: inviteCode },
|
||||||
}),
|
}),
|
||||||
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
|
||||||
|
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
* the recipient a concrete reason to join right away.
|
* the recipient a concrete reason to join right away.
|
||||||
*/
|
*/
|
||||||
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
|
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
|
||||||
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
|
const link = `https://sauci.app/join/${inviteCode}`;
|
||||||
if (sealedCount > 0) {
|
if (sealedCount > 0) {
|
||||||
const noun = sealedCount === 1 ? "question" : "questions";
|
const noun = sealedCount === 1 ? "question" : "questions";
|
||||||
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
|
return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
return `Join me on Sauci! Tap this link to pair up instantly: ${link}`;
|
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
const keyFor = (userId: string, coupleId: string) => `paired_unlock_seen_${userId}_${coupleId}`;
|
||||||
|
|
||||||
|
export async function hasSeenPairedUnlock(userId: string, coupleId: string): Promise<boolean> {
|
||||||
|
return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(keyFor(userId, coupleId), "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise<void> {
|
||||||
|
if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const PAIRING_PRIVACY_SUMMARY = "Vote-style answers reveal shared outcomes. Text, audio, photo, and who-likely responses are shown after you both answer.";
|
||||||
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||||
|
|
||||||
const STORAGE_KEY = "pending_invite_code";
|
const STORAGE_KEY = "pending_invite_code";
|
||||||
|
let storageOperation = Promise.resolve();
|
||||||
|
|
||||||
|
function withStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const next = storageOperation.then(operation, operation);
|
||||||
|
storageOperation = next.then(() => undefined, () => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingInviteCode {
|
||||||
|
code: string;
|
||||||
|
/** The account that received or first claimed this deferred invite. */
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingInviteCode(value: string | null): PendingInviteCode | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// Treat legacy, unscoped values as a pre-auth hand-off. The first account
|
||||||
|
// that intentionally enters the pairing flow claims it below.
|
||||||
|
if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null };
|
||||||
|
|
||||||
export async function stashPendingInviteCode(code: string): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
|
||||||
|
if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null;
|
||||||
|
if (parsed.userId !== null && typeof parsed.userId !== "string") return null;
|
||||||
|
return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error stashing pending invite code:", error);
|
console.error("Error stashing pending invite code:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingInviteCode(): Promise<string | null> {
|
export async function getPendingInviteCode(userId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
return await withStorageLock(async () => {
|
||||||
if (value && isValidInviteCode(value)) {
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
return normalizeInviteCode(value);
|
if (!pending || (pending.userId && pending.userId !== userId)) return null;
|
||||||
|
|
||||||
|
// 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 null;
|
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 withStorageLock(async () => {
|
||||||
|
if (userId) {
|
||||||
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
|
if (pending?.userId && pending.userId !== userId) return;
|
||||||
|
}
|
||||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing pending invite code:", error);
|
console.error("Error clearing pending invite code:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { coupleApi } from "../lib/coupleApi";
|
|||||||
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
||||||
import { syncTimezone } from "../lib/reportedTimezone";
|
import { syncTimezone } from "../lib/reportedTimezone";
|
||||||
import { Events } from "../lib/analytics";
|
import { Events } from "../lib/analytics";
|
||||||
|
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
|
||||||
|
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
|
||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
|
|
||||||
export interface AuthSessionSnapshot {
|
export interface AuthSessionSnapshot {
|
||||||
@@ -201,6 +203,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
Events.signOut();
|
Events.signOut();
|
||||||
|
|
||||||
|
// The paired-unlock presentation is scoped to this account and current
|
||||||
|
// relationship. Remove it before local identity state is cleared.
|
||||||
|
const unlockUserId = get().user?.id;
|
||||||
|
const unlockCoupleId = get().couple?.id ?? get().user?.couple_id ?? null;
|
||||||
|
if (unlockUserId) {
|
||||||
|
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
|
||||||
|
}
|
||||||
|
await clearPendingInviteCode(unlockUserId);
|
||||||
|
|
||||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -236,6 +247,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setUser: (user) => {
|
setUser: (user) => {
|
||||||
|
const priorUserId = user === null ? get().user?.id : undefined;
|
||||||
|
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
@@ -246,6 +259,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
|
void clearPendingInviteCode(priorUserId);
|
||||||
|
if (priorUserId) {
|
||||||
|
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
||||||
|
}
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean }
|
|||||||
// account's request for the same key, and a stale generation's `finally` must not
|
// account's request for the same key, and a stale generation's `finally` must not
|
||||||
// clear an entry a newer generation just claimed.
|
// clear an entry a newer generation just claimed.
|
||||||
const inFlight = new Map<string, number>();
|
const inFlight = new Map<string, number>();
|
||||||
|
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
|
||||||
|
|
||||||
// Rate limit: 12 hours in milliseconds
|
// Rate limit: 12 hours in milliseconds
|
||||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||||
@@ -80,7 +81,7 @@ interface MatchState {
|
|||||||
*/
|
*/
|
||||||
generation: number;
|
generation: number;
|
||||||
// Methods
|
// Methods
|
||||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
isNudging: false,
|
isNudging: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
|
||||||
fetchMatches: async (refresh = false, options = {}) => {
|
fetchMatches: (refresh = false, options = {}) => {
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
const myGeneration = get().generation;
|
const myGeneration = get().generation;
|
||||||
|
|
||||||
// Early return if no couple - user isn't paired yet
|
// Early return if no couple - user isn't paired yet
|
||||||
if (!coupleId) {
|
if (!coupleId) {
|
||||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||||
return;
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
const activeRequest = activeInFlight.get('active');
|
||||||
|
if (activeRequest?.generation === myGeneration) return activeRequest.promise;
|
||||||
|
if (state.isLoadingMore && !refresh) return Promise.resolve(false);
|
||||||
|
|
||||||
// Nothing on screen yet is the only case that warrants blanking the list
|
// Nothing on screen yet is the only case that warrants blanking the list
|
||||||
// for a spinner; every other refresh leaves the cached rows in place.
|
// for a spinner; every other refresh leaves the cached rows in place.
|
||||||
@@ -149,17 +152,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
if (refresh) {
|
if (refresh) {
|
||||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||||
} else {
|
} else {
|
||||||
if (!state.hasMore) return;
|
if (!state.hasMore) return Promise.resolve(false);
|
||||||
set({ isLoadingMore: true });
|
set({ isLoadingMore: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlight.set('active', myGeneration);
|
const request = (async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const currentPage = refresh ? 0 : state.page;
|
const currentPage = refresh ? 0 : state.page;
|
||||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||||
// A sign-out/account switch that happened while this request was in
|
// A sign-out/account switch that happened while this request was in
|
||||||
// flight must not let its response populate the next account's store.
|
// flight must not let its response populate the next account's store.
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
const nonArchivedMatches = result.matches;
|
const nonArchivedMatches = result.matches;
|
||||||
const totalCount = result.totalCount;
|
const totalCount = result.totalCount;
|
||||||
const archivedMatchIds = state.archivedMatchIds;
|
const archivedMatchIds = state.archivedMatchIds;
|
||||||
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
set({ isLoadingMore: false, hasMore: false });
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedData = nonArchivedMatches;
|
const sortedData = nonArchivedMatches;
|
||||||
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
loadedAt,
|
loadedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// An old generation's cleanup must not delete a newer generation's guard.
|
// An old generation's cleanup must not delete a newer generation's guard.
|
||||||
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
|
if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active');
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
activeInFlight.set('active', { generation: myGeneration, promise: request });
|
||||||
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsSeen: async (matchId) => {
|
markAsSeen: async (matchId) => {
|
||||||
@@ -296,6 +304,7 @@ clearMatches: () => {
|
|||||||
// A request belonging to the signed-out user must not suppress the next
|
// A request belonging to the signed-out user must not suppress the next
|
||||||
// one: the guard is a duplicate-request check, not a lock on the data.
|
// one: the guard is a duplicate-request check, not a lock on the data.
|
||||||
inFlight.clear();
|
inFlight.clear();
|
||||||
|
activeInFlight.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Archive methods
|
// Archive methods
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { redactInvitePaths } from './providers'
|
||||||
|
|
||||||
|
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
|
||||||
|
const providerPath = new URL('./providers.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('PostHog invite privacy boundary', () => {
|
||||||
|
it('does not send a global page view for invite routes', async () => {
|
||||||
|
const source = await readFile(pageViewPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("pathname?.startsWith('/join/')")
|
||||||
|
expect(source.indexOf("pathname?.startsWith('/join/')")).toBeLessThan(source.indexOf("posthog.capture('$pageview'"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables automatic page-leave capture and redacts join URLs', async () => {
|
||||||
|
const source = await readFile(providerPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('capture_pageview: false')
|
||||||
|
expect(source).toContain('capture_pageleave: false')
|
||||||
|
expect(source).toContain('get_current_url: redactInviteUrl')
|
||||||
|
expect(source).toContain('before_send: redactInvitePathsBeforeSend')
|
||||||
|
expect(source).toContain("url.pathname.startsWith('/join/')")
|
||||||
|
expect(source).toContain('/join/[invite]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an invite code from every serialized URL or path property', () => {
|
||||||
|
const inviteCode = 'A4K9BT2Q'
|
||||||
|
const event = {
|
||||||
|
event: 'join_page_viewed',
|
||||||
|
properties: {
|
||||||
|
$current_url: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
$pathname: `/join/${inviteCode}`,
|
||||||
|
$referrer: `https://sauci.app/join/${inviteCode}?source=message`,
|
||||||
|
$session_entry_url: `https://sauci.app/join/${inviteCode}`,
|
||||||
|
nested: { previous_path: `/join/${inviteCode}` },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(redactInvitePaths(event))
|
||||||
|
|
||||||
|
expect(serialized).not.toContain(inviteCode)
|
||||||
|
expect(serialized).toContain('/join/[invite]')
|
||||||
|
expect(serialized).toContain('join_page_viewed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,10 @@ function PostHogPageViewInner() {
|
|||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 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) {
|
if (pathname && posthog) {
|
||||||
let url = window.origin + pathname
|
let url = window.origin + pathname
|
||||||
if (searchParams.toString()) {
|
if (searchParams.toString()) {
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ body {
|
|||||||
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 {
|
||||||
background: linear-gradient(135deg, #e94560 0%, #9b59b6 100%);
|
background: linear-gradient(135deg, #e94560 0%, #9b59b6 100%);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
<Link href="/" className="btn-primary inline-block">
|
<Link href="/" className="btn-primary inline-block">
|
||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</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 && (
|
||||||
|
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status" aria-live="polite">
|
||||||
|
Nothing opened? You probably don't have Sauci yet.{' '}
|
||||||
|
<button type="button" className="underline font-medium text-white" onClick={() => void handleCopy('fallback_note')}>
|
||||||
|
Copy your code
|
||||||
|
</button>
|
||||||
|
, then install below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section aria-labelledby="install-heading">
|
||||||
|
<h2 id="install-heading" className="text-white/70 text-sm font-medium mb-4">Get Sauci</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<a
|
<a
|
||||||
href={APP_STORE_URL}
|
href={APP_STORE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => handleStoreClick('app_store')}
|
onClick={(event) => {
|
||||||
className="btn-primary w-full sm:w-auto"
|
if (!isOnline) event.preventDefault()
|
||||||
|
handleStoreClick('app_store')
|
||||||
|
}}
|
||||||
|
aria-disabled={!isOnline}
|
||||||
|
title={storeUnavailableTitle}
|
||||||
|
className={`${platform === 'ios' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Download on the App Store
|
Get it on the App Store
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={PLAY_STORE_URL}
|
href={PLAY_STORE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => handleStoreClick('play_store')}
|
onClick={(event) => {
|
||||||
className="btn-secondary w-full sm:w-auto"
|
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
|
Get it on Google Play
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,20 +2,79 @@
|
|||||||
|
|
||||||
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 type { CaptureResult } from 'posthog-js'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
|
||||||
|
|
||||||
|
function redactInviteUrl(defaultUrl: string): string {
|
||||||
|
const url = new URL(defaultUrl)
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/join/')) {
|
||||||
|
return `${url.origin}/join/[invite]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactInvitePaths<T>(value: T): T {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.replace(INVITE_PATH_PATTERN, '/join/[invite]') as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(redactInvitePaths) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactInvitePaths(entry)])) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactInvitePathsBeforeSend(event: CaptureResult | null): CaptureResult | null {
|
||||||
|
return event ? redactInvitePaths(event) : null
|
||||||
|
}
|
||||||
|
|
||||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
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, {
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
||||||
person_profiles: 'identified_only',
|
person_profiles: 'identified_only',
|
||||||
capture_pageview: false, // We'll capture pageviews manually for better control
|
capture_pageview: false,
|
||||||
capture_pageleave: true,
|
capture_pageleave: false,
|
||||||
|
get_current_url: redactInviteUrl,
|
||||||
|
before_send: redactInvitePathsBeforeSend,
|
||||||
|
// This must be part of initialization, not only the navigation effect
|
||||||
|
// below, so an initially loaded invite page is protected before its DOM
|
||||||
|
// can be considered for capture or recording.
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
|
||||||
|
|
||||||
|
// Invite codes are credentials. Do not let their page enter PostHog's DOM
|
||||||
|
// autocapture or session-recording pipeline, even though event properties
|
||||||
|
// and URLs are separately redacted below.
|
||||||
|
const isInvitePage = pathname?.startsWith('/join/')
|
||||||
|
posthog.set_config({
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
|
})
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
|
|||||||
- All messages
|
- All messages
|
||||||
- All couple_packs settings
|
- All couple_packs settings
|
||||||
|
|
||||||
|
### Standalone API invite cancellation (DELETE)
|
||||||
|
|
||||||
|
`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated
|
||||||
|
creator only. It is intentionally separate from `DELETE /v1/couple`, which
|
||||||
|
continues to end an existing relationship for legacy clients.
|
||||||
|
|
||||||
|
The server locks the authenticated profile and then the couple row in one
|
||||||
|
transaction. It deletes the invite only when that caller is the sole member.
|
||||||
|
If another member has joined, it returns `409 invite_already_joined` and makes
|
||||||
|
no changes. This serializes safely with a concurrent `POST /v1/couple` join:
|
||||||
|
one operation completes first, while the other observes the resulting state.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Response on a cancelled waiting invite
|
||||||
|
{ success: true, couple_id: null }
|
||||||
|
```
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### Couple Record
|
### Couple Record
|
||||||
@@ -178,6 +195,18 @@ This prevents race conditions where multiple users try to join simultaneously.
|
|||||||
|
|
||||||
**Waiting State:**
|
**Waiting State:**
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The invite journey leads with the shared outcome, then shows the recoverable
|
||||||
|
eight-character code. It offers both a bare-code copy and a full
|
||||||
|
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
|
||||||
|
removes only an unjoined invite. Sealed answers made before invite creation
|
||||||
|
remain, while answers made while waiting are deleted with the invite. The
|
||||||
|
five-second poll surfaces an inline offline state but continues retrying.
|
||||||
|
|
||||||
|
When pairing succeeds, both members are routed once per account and couple to
|
||||||
|
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
|
||||||
|
reveals unmatched or individual answers. Existing swipe remains the named
|
||||||
|
post-pair question entry until Quick Spark is implemented.
|
||||||
┌─────────────────────────────┐
|
┌─────────────────────────────┐
|
||||||
│ ❤️ Partner Code │
|
│ ❤️ Partner Code │
|
||||||
│ │
|
│ │
|
||||||
@@ -360,6 +389,11 @@ The code is never applied automatically; the user must accept the offer.
|
|||||||
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
||||||
(web, PostHog).
|
(web, PostHog).
|
||||||
|
|
||||||
|
The mobile invite funnel also records aggregate-only prompt, share, join,
|
||||||
|
failure, completion, unlock, cancellation, clipboard, and conflict events.
|
||||||
|
Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and
|
||||||
|
answers are never sent as analytics properties.
|
||||||
|
|
||||||
## Cascade Behavior
|
## Cascade Behavior
|
||||||
|
|
||||||
When a couple is deleted, foreign key cascades clean up:
|
When a couple is deleted, foreign key cascades clean up:
|
||||||
@@ -381,4 +415,5 @@ Storage (chat-media) is manually cleaned before the cascade.
|
|||||||
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
|
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
|
||||||
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
||||||
| Couple full | Couple already has 2 members | "This couple already has two partners" |
|
| Couple full | Couple already has 2 members | "This couple already has two partners" |
|
||||||
|
| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" |
|
||||||
| DB trigger | Race condition caught | "A couple can only have 2 members" |
|
| DB trigger | Race condition caught | "A couple can only have 2 members" |
|
||||||
Reference in new issue
Block a user