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>;
|
||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||
cancelInvite(userId: string): Promise<void>;
|
||||
cancel(userId: string): 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> {
|
||||
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));
|
||||
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);
|
||||
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'
|
||||
| 'couple_full'
|
||||
| 'not_paired'
|
||||
| 'invite_already_joined'
|
||||
| 'invite_code_collision';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
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 { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
||||
import { CoupleService } from '../src/domains/couples/service.js';
|
||||
import { CoupleError } from '../src/domains/couples/types.js';
|
||||
|
||||
const identity: AuthIdentity = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
@@ -25,6 +26,7 @@ function setup() {
|
||||
invite_code: inviteCode,
|
||||
created_at: '2026-08-27T00:00:00.000Z',
|
||||
})),
|
||||
cancelInvite: vi.fn(async () => undefined),
|
||||
cancel: 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(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 })),
|
||||
create: vi.fn(async () => couple),
|
||||
join: vi.fn(async () => couple),
|
||||
cancelInvite: vi.fn(async () => undefined),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
|
||||
expect(create).toHaveBeenCalledTimes(2);
|
||||
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 isOnOnboarding = segmentStrings.includes("onboarding");
|
||||
const isOnPairing = segmentStrings.includes("pairing");
|
||||
const isOnPaired = segmentStrings.includes("paired");
|
||||
const isOnChat = segmentStrings.includes("chat");
|
||||
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -367,6 +368,7 @@ export default function AppLayout() {
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="paired" options={{ href: null }} />
|
||||
<Tabs.Screen
|
||||
name="chat/[id]"
|
||||
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,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
AccessibilityInfo,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||
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() {
|
||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string }>();
|
||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||
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
|
||||
// computes matches server-side, so the matches store must refetch here too:
|
||||
// whichever partner lands on this redirect first would otherwise show stale,
|
||||
// pre-pairing matches until some unrelated screen happened to refresh it.
|
||||
useEffect(() => {
|
||||
if (couple && partner) {
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
router.replace("/(app)");
|
||||
}
|
||||
}, [couple, partner]);
|
||||
if (!couple || !partner || !user) return;
|
||||
let cancelled = false;
|
||||
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)") }]);
|
||||
}
|
||||
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
|
||||
// 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) : "";
|
||||
if (isValidInviteCode(routeCode)) {
|
||||
await applyPrefill(routeCode);
|
||||
await clearPendingInviteCode();
|
||||
await clearPendingInviteCode(user?.id);
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stashedCode = await getPendingInviteCode();
|
||||
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
|
||||
if (stashedCode) {
|
||||
await applyPrefill(stashedCode);
|
||||
await clearPendingInviteCode();
|
||||
await clearPendingInviteCode(user?.id);
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +110,7 @@ export default function PairingScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [couple, params.code, prefillAttempted]);
|
||||
}, [couple, params.code, prefillAttempted, user?.id]);
|
||||
|
||||
const acceptClipboardOffer = () => {
|
||||
if (!clipboardOfferCode) return;
|
||||
@@ -113,7 +132,7 @@ export default function PairingScreen() {
|
||||
|
||||
// Poll every 5 seconds as fallback
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchCouple();
|
||||
void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
@@ -140,11 +159,12 @@ export default function PairingScreen() {
|
||||
// Validate invite code format (8 alphanumeric characters)
|
||||
const sanitizedCode = inviteCode.trim().toUpperCase();
|
||||
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;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setJoinError(null);
|
||||
try {
|
||||
await coupleApi.join(sanitizedCode);
|
||||
|
||||
@@ -152,14 +172,26 @@ export default function PairingScreen() {
|
||||
await fetchCouple(); // Fetch couple data
|
||||
// Joining claims both members' sealed answers and computes matches
|
||||
// server-side, so the joiner needs a fresh matches list too.
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
Events.coupleJoined();
|
||||
|
||||
Alert.alert("Success", "You are now paired!", [
|
||||
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
|
||||
]);
|
||||
router.replace("/(app)/paired" as 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 {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -176,6 +208,7 @@ export default function PairingScreen() {
|
||||
const handleInviteCodeChange = (value: string) => {
|
||||
setWasPrefilled(false);
|
||||
setInviteCode(value);
|
||||
setJoinError(null);
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
@@ -210,25 +243,57 @@ export default function PairingScreen() {
|
||||
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 () => {
|
||||
Alert.alert(
|
||||
"Cancel Pairing",
|
||||
"Are you sure you want to cancel? Your invite code will be deleted.",
|
||||
"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 Waiting", style: "cancel" },
|
||||
{ text: "Keep invite", style: "cancel" },
|
||||
{
|
||||
text: "Cancel Pairing",
|
||||
text: "Cancel invite",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.cancel();
|
||||
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
Events.pairingCancelled();
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
await cancelInvite();
|
||||
} finally {
|
||||
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) {
|
||||
return (
|
||||
<GradientBackground>
|
||||
@@ -265,10 +359,22 @@ export default function PairingScreen() {
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Partner Code</Text>
|
||||
<Text style={styles.title}>Your invite</Text>
|
||||
</Animated.View>
|
||||
|
||||
<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 */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
@@ -283,7 +389,7 @@ export default function PairingScreen() {
|
||||
<Ionicons name="heart" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<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>
|
||||
</Animated.View>
|
||||
|
||||
@@ -295,8 +401,7 @@ export default function PairingScreen() {
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
|
||||
They will unlock the moment your partner joins.
|
||||
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.
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
@@ -313,12 +418,12 @@ export default function PairingScreen() {
|
||||
onPress={copyToClipboard}
|
||||
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}>
|
||||
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.tapToCopy}>Tap to copy</Text>
|
||||
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
@@ -341,7 +446,7 @@ export default function PairingScreen() {
|
||||
size="lg"
|
||||
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
||||
>
|
||||
Share Invite Code
|
||||
Share invite
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
|
||||
@@ -358,6 +463,10 @@ export default function PairingScreen() {
|
||||
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
||||
</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>
|
||||
|
||||
{/* Waiting indicator */}
|
||||
@@ -367,8 +476,9 @@ export default function PairingScreen() {
|
||||
>
|
||||
<View style={styles.waitingBadge}>
|
||||
<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>
|
||||
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
|
||||
</Animated.View>
|
||||
|
||||
{/* Cancel Button */}
|
||||
@@ -382,7 +492,7 @@ export default function PairingScreen() {
|
||||
style={styles.cancelButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
|
||||
<Text style={styles.cancelButtonText}>Cancel invite</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -427,7 +537,7 @@ export default function PairingScreen() {
|
||||
<Ionicons name="link" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.subtitle}>
|
||||
Link with your partner to start matching
|
||||
{PAIRING_PRIVACY_SUMMARY}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
@@ -481,7 +591,12 @@ export default function PairingScreen() {
|
||||
/>
|
||||
{wasPrefilled && (
|
||||
<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>
|
||||
)}
|
||||
<GlassButton
|
||||
@@ -490,7 +605,7 @@ export default function PairingScreen() {
|
||||
loading={isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
Join Partner
|
||||
Join your partner
|
||||
</GlassButton>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
@@ -516,7 +631,7 @@ export default function PairingScreen() {
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
Create New Code
|
||||
Create invite code
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -723,6 +838,28 @@ const styles = StyleSheet.create({
|
||||
...typography.subhead,
|
||||
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: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
|
||||
@@ -38,6 +38,7 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||
}
|
||||
|
||||
import { Stack, router, usePathname } from "expo-router";
|
||||
import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
// GestureHandlerRootView is only needed on native platforms
|
||||
@@ -130,12 +131,18 @@ export default function RootLayout() {
|
||||
const inviteLink = parseInviteLinkCode(url);
|
||||
if (inviteLink) {
|
||||
Events.inviteLinkOpened(inviteLink.source);
|
||||
await stashPendingInviteCode(inviteLink.code);
|
||||
|
||||
const { isAuthenticated, user } = useAuthStore.getState();
|
||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -265,6 +272,7 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ReducedMotionConfig mode={ReduceMotion.System} />
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
|
||||
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
||||
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
|
||||
|
||||
// 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).
|
||||
@@ -18,16 +17,17 @@ export default function Index() {
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
getPendingInviteCode().then((code) => {
|
||||
if (!user?.id) return;
|
||||
setPendingInviteCheck(null);
|
||||
getPendingInviteCode(user.id).then((code) => {
|
||||
if (cancelled) return;
|
||||
setHasPendingInvite(!!code);
|
||||
setPendingCodeChecked(true);
|
||||
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
||||
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
|
||||
|
||||
// Show loading state while auth is being determined
|
||||
if (isLoading) {
|
||||
@@ -56,7 +56,7 @@ export default function Index() {
|
||||
// Wait for the one-time pending invite code check before deciding the
|
||||
// route, so a stashed code from a join link is applied without an extra
|
||||
// trip through the home screen.
|
||||
if (!pendingCodeChecked) {
|
||||
if (pendingInviteCheck?.userId !== user?.id) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
@@ -64,7 +64,7 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPendingInvite) {
|
||||
if (pendingInviteCheck?.hasInvite) {
|
||||
return <Redirect href="/(app)/pairing" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
|
||||
await coupleApi.getState();
|
||||
await coupleApi.create();
|
||||
await coupleApi.join("ABCD2345");
|
||||
await coupleApi.cancelInvite();
|
||||
await coupleApi.cancel();
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["/v1/couple"],
|
||||
["/v1/couple", { method: "POST", body: {} }],
|
||||
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
||||
["/v1/couple/invite", { method: "DELETE" }],
|
||||
["/v1/couple", { method: "DELETE" }],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
||||
|
||||
describe("buildInviteShareMessage", () => {
|
||||
it("leads with a plain invite when there are no sealed answers", () => {
|
||||
const message = buildInviteShareMessage("ABCD1234", 0);
|
||||
expect(message).toBe(
|
||||
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
||||
);
|
||||
it("keeps a recoverable bare code in a separate sentence", () => {
|
||||
const message = buildInviteShareMessage("AB12CD34", 3);
|
||||
expect(message).toContain("https://sauci.app/join/AB12CD34");
|
||||
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", () => {
|
||||
const message = buildInviteShareMessage("ABCD1234", 1);
|
||||
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.");
|
||||
it("does not claim a sealed outcome before answers exist", () => {
|
||||
expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
it("stashes, retrieves, and clears a valid code", async () => {
|
||||
await stashPendingInviteCode("abcd1234");
|
||||
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
||||
it("stashes, retrieves, and clears a valid code for its owner", async () => {
|
||||
await stashPendingInviteCode("abcd1234", "user-a");
|
||||
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||
|
||||
await clearPendingInviteCode();
|
||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||
await clearPendingInviteCode("user-a");
|
||||
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 { useSubscriptionStore } from "@/store/subscriptionStore";
|
||||
import type { Profile } from "@/types";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const profile = {
|
||||
id: "me",
|
||||
@@ -259,6 +260,7 @@ describe("authStore", () => {
|
||||
|
||||
it("clears sealed answers during an explicit sign-out", async () => {
|
||||
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
|
||||
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
|
||||
|
||||
await useAuthStore.getState().signOut();
|
||||
|
||||
@@ -267,6 +269,7 @@ describe("authStore", () => {
|
||||
sealedCount: 0,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
|
||||
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
|
||||
|
||||
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
|
||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
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);
|
||||
release({ matches: [], totalCount: 0 });
|
||||
await inFlight;
|
||||
await overlapping;
|
||||
|
||||
(apiClient.get as jest.Mock).mockClear();
|
||||
useMatchStore.setState({ hasMore: false });
|
||||
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
|
||||
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 () => {
|
||||
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))];
|
||||
|
||||
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
|
||||
onPress={onInvitePress}
|
||||
activeOpacity={0.8}
|
||||
testID="swipe-sealed-answers-banner"
|
||||
accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
|
||||
>
|
||||
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
||||
<Text style={styles.text}>
|
||||
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
|
||||
</Text>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
|
||||
<Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
|
||||
</View>
|
||||
<Text style={styles.cta}>Invite</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.full,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
copy: { flex: 1, gap: 2 },
|
||||
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
|
||||
text: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
|
||||
@@ -281,6 +281,18 @@ export const Events = {
|
||||
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||
logEvent("invite_link_opened", { source }),
|
||||
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
|
||||
quizStarted: () => logEvent("quiz_started"),
|
||||
|
||||
@@ -24,4 +24,5 @@ export const coupleApi = {
|
||||
body: { invite_code: inviteCode },
|
||||
}),
|
||||
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.
|
||||
*/
|
||||
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) {
|
||||
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";
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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 {
|
||||
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (value && isValidInviteCode(value)) {
|
||||
return normalizeInviteCode(value);
|
||||
}
|
||||
return null;
|
||||
return await withStorageLock(async () => {
|
||||
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
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 pending.code;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reading pending invite code:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPendingInviteCode(): Promise<void> {
|
||||
export async function clearPendingInviteCode(userId?: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
await withStorageLock(async () => {
|
||||
if (userId) {
|
||||
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
if (pending?.userId && pending.userId !== userId) return;
|
||||
}
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error clearing pending invite code:", error);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { coupleApi } from "../lib/coupleApi";
|
||||
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
||||
import { syncTimezone } from "../lib/reportedTimezone";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
|
||||
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
|
||||
export interface AuthSessionSnapshot {
|
||||
@@ -201,6 +203,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
signOut: async () => {
|
||||
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
|
||||
set({
|
||||
user: null,
|
||||
@@ -236,6 +247,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
const priorUserId = user === null ? get().user?.id : undefined;
|
||||
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
@@ -246,6 +259,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
void clearPendingInviteCode(priorUserId);
|
||||
if (priorUserId) {
|
||||
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
||||
}
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
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
|
||||
// clear an entry a newer generation just claimed.
|
||||
const inFlight = new Map<string, number>();
|
||||
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
|
||||
|
||||
// Rate limit: 12 hours in milliseconds
|
||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
@@ -80,7 +81,7 @@ interface MatchState {
|
||||
*/
|
||||
generation: number;
|
||||
// Methods
|
||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
isNudging: false,
|
||||
generation: 0,
|
||||
|
||||
fetchMatches: async (refresh = false, options = {}) => {
|
||||
fetchMatches: (refresh = false, options = {}) => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
|
||||
// Early return if no couple - user isn't paired yet
|
||||
if (!coupleId) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||
return;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
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
|
||||
// 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) {
|
||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||
} else {
|
||||
if (!state.hasMore) return;
|
||||
if (!state.hasMore) return Promise.resolve(false);
|
||||
set({ isLoadingMore: true });
|
||||
}
|
||||
|
||||
inFlight.set('active', myGeneration);
|
||||
try {
|
||||
const request = (async (): Promise<boolean> => {
|
||||
try {
|
||||
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}`);
|
||||
// A sign-out/account switch that happened while this request was in
|
||||
// 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 totalCount = result.totalCount;
|
||||
const archivedMatchIds = state.archivedMatchIds;
|
||||
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const sortedData = nonArchivedMatches;
|
||||
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
loadedAt,
|
||||
};
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
if (get().generation !== myGeneration) return false;
|
||||
console.error("Error fetching matches:", err);
|
||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||
return false;
|
||||
} finally {
|
||||
// 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) => {
|
||||
@@ -296,6 +304,7 @@ clearMatches: () => {
|
||||
// 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.
|
||||
inFlight.clear();
|
||||
activeInFlight.clear();
|
||||
},
|
||||
|
||||
// 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()
|
||||
|
||||
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) {
|
||||
let url = window.origin + pathname
|
||||
if (searchParams.toString()) {
|
||||
|
||||
@@ -70,6 +70,13 @@ body {
|
||||
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 {
|
||||
background: linear-gradient(135deg, #e94560 0%, #9b59b6 100%);
|
||||
|
||||
@@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => {
|
||||
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')
|
||||
|
||||
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('Copy code')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
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_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 () => {
|
||||
const source = await readFile(componentPath, 'utf8')
|
||||
|
||||
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 { 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 }) {
|
||||
const posthog = usePostHog()
|
||||
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 normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code])
|
||||
|
||||
// Track the page view once. Fires regardless of code validity so we can
|
||||
// see broken/expired links in the funnel too.
|
||||
useEffect(() => {
|
||||
posthog?.capture('join_page_viewed', { code_valid: isValid })
|
||||
const detectedPlatform = getPlatform()
|
||||
setPlatform(detectedPlatform)
|
||||
posthog?.capture('join_page_viewed', { code_valid: isValid, platform: detectedPlatform })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Attempt to open the app via the custom scheme once. If the app is
|
||||
// installed and registered for the scheme, this hands off to it; if not,
|
||||
// nothing visible happens and the fallback UI below remains in view.
|
||||
useEffect(() => {
|
||||
if (!isValid || hasAttemptedOpen.current) return
|
||||
hasAttemptedOpen.current = true
|
||||
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
||||
}, [isValid, normalizedCode])
|
||||
const updateOnlineStatus = () => setIsOnline(navigator.onLine)
|
||||
|
||||
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 {
|
||||
await navigator.clipboard.writeText(normalizedCode)
|
||||
hasCopiedCode.current = true
|
||||
setCopied(true)
|
||||
posthog?.capture('join_page_code_copied')
|
||||
posthog?.capture('join_page_code_copied', { trigger })
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (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') => {
|
||||
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) {
|
||||
const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
||||
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
|
||||
<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>
|
||||
<p className="text-white/60 mb-8">
|
||||
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
|
||||
<p className="text-white/70 mb-8">
|
||||
This invite link looks incomplete. Ask your partner to send a new one from the Sauci app.
|
||||
</p>
|
||||
<Link href="/" className="btn-primary inline-block">
|
||||
Back to Home
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<Link href="/" className="btn-primary inline-block">
|
||||
Back to Home
|
||||
</Link>
|
||||
<a
|
||||
href={malformedStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary inline-block"
|
||||
>
|
||||
Get Sauci
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</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 (
|
||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||
{/* Header */}
|
||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background ph-no-capture ph-mask">
|
||||
<header className="py-6 px-6 border-b border-white/5">
|
||||
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
|
||||
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Back to Home</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
|
||||
<Heart className="w-10 h-10 text-primary" />
|
||||
{!isOnline && (
|
||||
<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>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to Sauci</h1>
|
||||
<p className="text-white/60 mb-10">
|
||||
Open the app to pair up instantly. If it doesn't open automatically, copy your code below and
|
||||
enter it after you install Sauci.
|
||||
</p>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
||||
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
||||
|
||||
<div className="glass-light p-6 sm:p-8 mb-8">
|
||||
<p className="text-white/60 text-sm mb-3">Your invite code</p>
|
||||
<section className="surface-solid p-6 sm:p-8 mb-6 ph-no-capture ph-mask" aria-labelledby="invite-code-heading">
|
||||
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
||||
<p
|
||||
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||
data-testid="invite-code-display"
|
||||
aria-label={normalizedCode.split('').join(' ')}
|
||||
>
|
||||
{normalizedCode}
|
||||
</p>
|
||||
<p className="text-white/70 text-sm mb-5">Copy this before you install. You'll enter it in the app.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
onClick={() => void handleCopy('code_block')}
|
||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -111,33 +177,59 @@ export function JoinClient({ code }: { code: string }) {
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
<a
|
||||
href={APP_STORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => handleStoreClick('app_store')}
|
||||
className="btn-primary w-full sm:w-auto"
|
||||
>
|
||||
Download on the App Store
|
||||
</a>
|
||||
<a
|
||||
href={PLAY_STORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => handleStoreClick('play_store')}
|
||||
className="btn-secondary w-full sm:w-auto"
|
||||
>
|
||||
Get it on Google Play
|
||||
</a>
|
||||
</div>
|
||||
{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
|
||||
href={APP_STORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => {
|
||||
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' : ''}`}
|
||||
>
|
||||
Get it on the App Store
|
||||
</a>
|
||||
<a
|
||||
href={PLAY_STORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => {
|
||||
if (!isOnline) event.preventDefault()
|
||||
handleStoreClick('play_store')
|
||||
}}
|
||||
aria-disabled={!isOnline}
|
||||
title={storeUnavailableTitle}
|
||||
className={`${platform === 'android' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
Get it on Google Play
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
||||
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
|
||||
import { JoinClient } from './JoinClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join your partner on Sauci',
|
||||
description: 'Open Sauci and pair up instantly with your invite code.',
|
||||
title: 'Compare answers together on Sauci',
|
||||
description: 'Copy your invite code, then join Sauci to make shared discoveries together.',
|
||||
}
|
||||
|
||||
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 { PostHogProvider as PHProvider } from 'posthog-js/react'
|
||||
import type { CaptureResult } from 'posthog-js'
|
||||
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 }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
const isInvitePage = window.location.pathname.startsWith('/join/')
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: false, // We'll capture pageviews manually for better control
|
||||
capture_pageleave: true,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
get_current_url: redactInviteUrl,
|
||||
before_send: redactInvitePathsBeforeSend,
|
||||
// This must be part of initialization, not only the navigation effect
|
||||
// below, so an initially loaded invite page is protected before its DOM
|
||||
// can be considered for capture or recording.
|
||||
autocapture: !isInvitePage,
|
||||
disable_session_recording: isInvitePage,
|
||||
mask_all_text: isInvitePage,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
|
||||
|
||||
// Invite codes are credentials. Do not let their page enter PostHog's DOM
|
||||
// autocapture or session-recording pipeline, even though event properties
|
||||
// and URLs are separately redacted below.
|
||||
const isInvitePage = pathname?.startsWith('/join/')
|
||||
posthog.set_config({
|
||||
autocapture: !isInvitePage,
|
||||
disable_session_recording: isInvitePage,
|
||||
mask_all_text: isInvitePage,
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
|
||||
- All messages
|
||||
- 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
|
||||
|
||||
### Couple Record
|
||||
@@ -178,6 +195,18 @@ This prevents race conditions where multiple users try to join simultaneously.
|
||||
|
||||
**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 │
|
||||
│ │
|
||||
@@ -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`
|
||||
(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
|
||||
|
||||
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" |
|
||||
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
|
||||
| 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" |
|
||||
Reference in new issue
Block a user