Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions __tests__/share-waitlist.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,33 @@ const screenSource = readFileSync(
"utf8",
);

test("renders the waitlist capture in both former CTA locations", () => {
assert.equal((screenSource.match(/<WaitlistCapture/g) ?? []).length, 2);
// These are source-text checks with known blind spots: they will not catch a
// semantically equivalent reintroduction under different JSX or import syntax.
// Real rendered-output assertions would require adding @testing-library/react-native
// or react-test-renderer as a new dependency in a separate approved task.
test("does not render public share waitlist capture CTAs", () => {
assert.doesNotMatch(screenSource, /<WaitlistCapture/);
assert.doesNotMatch(screenSource, /function WaitlistCapture/);
assert.doesNotMatch(screenSource, /import \{ Link,/);
assert.doesNotMatch(screenSource, /href="\/"/);
assert.match(screenSource, /Join the App Store waitlist/);
});

test("captures an email and renders friendly terminal states", () => {
assert.match(screenSource, /joinWaitlist\(email\)/);
assert.match(screenSource, /inputMode="email"/);
assert.match(screenSource, /keyboardType="email-address"/);
assert.match(screenSource, /textContentType="emailAddress"/);
assert.match(
// These are source-text checks with known blind spots: they will not catch a
// semantically equivalent reintroduction under different JSX or import syntax.
// Real rendered-output assertions would require adding @testing-library/react-native
// or react-test-renderer as a new dependency in a separate approved task.
test("does not collect anonymous visitor email addresses", () => {
assert.doesNotMatch(screenSource, /joinWaitlist/);
assert.doesNotMatch(screenSource, /TextInput/);
assert.doesNotMatch(screenSource, /inputMode="email"/);
assert.doesNotMatch(screenSource, /keyboardType="email-address"/);
assert.doesNotMatch(screenSource, /textContentType="emailAddress"/);
assert.doesNotMatch(screenSource, /Join the App Store waitlist/);
assert.doesNotMatch(
screenSource,
/You're on the list — we'll email you when BestList is live\./,
);
assert.match(
assert.doesNotMatch(
screenSource,
/You're already on the list — we'll email you when BestList is live\./,
);
Expand Down
1 change: 1 addition & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"backgroundColor": "#F5F0E8"
}
],
"expo-secure-store",
[
"expo-build-properties",
{
Expand Down
2 changes: 1 addition & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Sentry.init({
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
integrations: [Sentry.mobileReplayIntegration()],

// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
Expand Down
117 changes: 0 additions & 117 deletions app/share/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
ScrollView,
Share,
Text,
TextInput,
View,
type ImageSourcePropType,
} from "react-native";
Expand All @@ -20,7 +19,6 @@ import {
getPublicCategoryByShareId,
getPublicCategoryOwnerUsername,
getPublicEntries,
joinWaitlist,
} from "@/lib/api";
import { getCategoryShareUrl } from "@/lib/category-sharing";
import { calculateOverallScore, sortEntries } from "@/lib/entry-score";
Expand Down Expand Up @@ -166,11 +164,6 @@ export default function ShareListScreen() {
<Text className="text-center font-body text-[15px] leading-6 text-secondary">
This shared BestList is not available.
</Text>

<WaitlistCapture
buttonClassName="mt-4 h-12 items-center justify-center rounded-full bg-accent px-6 shadow-card"
labelClassName="font-body text-[16px] font-bold text-white"
/>
</View>
</View>
</SafeAreaView>
Expand Down Expand Up @@ -235,10 +228,6 @@ export default function ShareListScreen() {
<Text className="text-card-title text-primary">
Best<Text className="text-accent">List</Text>
</Text>
<WaitlistCapture
buttonClassName="h-11 items-center justify-center rounded-full bg-accent px-6 shadow-card"
labelClassName="text-label uppercase text-white"
/>
</View>
</View>
</ScrollView>
Expand Down Expand Up @@ -266,112 +255,6 @@ function SharedListLogo() {
);
}

type WaitlistCaptureProps = {
buttonClassName: string;
labelClassName: string;
};

type WaitlistState = "idle" | "form" | "joined" | "already_joined";

function WaitlistCapture({
buttonClassName,
labelClassName,
}: WaitlistCaptureProps) {
const [email, setEmail] = useState("");
const [state, setState] = useState<WaitlistState>("idle");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

async function submitWaitlist() {
const normalizedEmail = email.trim().toLowerCase();
const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);

if (!isValidEmail) {
setErrorMessage("Enter a valid email address.");
return;
}

setErrorMessage(null);
setIsSubmitting(true);

try {
const result = await joinWaitlist(email);
setState(result);
} catch (error: unknown) {
console.error(
"Failed to join waitlist:",
error instanceof Error ? error.message : String(error),
);
setErrorMessage("We couldn't save your email. Please try again.");
} finally {
setIsSubmitting(false);
}
}

if (state === "joined") {
return (
<Text className="max-w-sm text-center font-body text-[14px] leading-5 text-accent">
{"You're on the list — we'll email you when BestList is live."}
</Text>
);
}

if (state === "already_joined") {
return (
<Text className="max-w-sm text-center font-body text-[14px] leading-5 text-accent">
{"You're already on the list — we'll email you when BestList is live."}
</Text>
);
}

if (state === "idle") {
return (
<Pressable
accessibilityRole="button"
className={buttonClassName}
onPress={() => setState("form")}
>
<Text className={labelClassName}>Join the App Store waitlist</Text>
</Pressable>
);
}

return (
<View className="w-full max-w-sm gap-2">
<TextInput
autoCapitalize="none"
autoComplete="email"
className="h-12 rounded-full border border-subtle bg-white px-5 font-body text-[16px] text-primary"
editable={!isSubmitting}
inputMode="email"
keyboardType="email-address"
onChangeText={setEmail}
onSubmitEditing={() => void submitWaitlist()}
placeholder="you@example.com"
placeholderTextColor="#78716C"
returnKeyType="done"
textContentType="emailAddress"
value={email}
/>
{errorMessage ? (
<Text className="text-center font-body text-[13px] leading-5 text-red-700">
{errorMessage}
</Text>
) : null}
<Pressable
accessibilityRole="button"
className="h-11 items-center justify-center rounded-full bg-accent px-6 shadow-card disabled:opacity-60"
disabled={isSubmitting}
onPress={() => void submitWaitlist()}
>
<Text className="text-label uppercase text-white">
{isSubmitting ? "Joining…" : "Join waitlist"}
</Text>
</Pressable>
</View>
);
}

type SharedEntryCardProps = {
entry: Entry;
rank: number;
Expand Down
6 changes: 3 additions & 3 deletions components/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ export function HomeScreen() {
{
text: "Create account",
onPress: () => {
// Temporary placeholder: plain sign-up creates a separate account today
// and does not carry over the guest's existing lists. Real
// guest-to-account linking is scope 3.
// Email sign-up links the current guest user/data via linkGuestEmail.
// Google and Apple guest linking are not built yet; AuthScreen blocks
// those guest buttons with a "use email for now" message.
router.push(signUpRoute);
},
},
Expand Down
30 changes: 0 additions & 30 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ type UpdateEntryPayload = Omit<
"id" | "categoryId" | "createdAt" | "overallScore"
>;

type WaitlistJoinResult = "joined" | "already_joined";

const categoryColumns =
"id,name,cover_photo,tone,is_shared,share_id,created_at";
const entryColumns =
Expand Down Expand Up @@ -328,34 +326,6 @@ export async function getPublicCategoryOwnerUsername(
return data;
}

/**
* Adds an email address to the public share waitlist.
*
* @param email - The email address to register.
* @returns Whether the email joined or was already registered.
*/
export async function joinWaitlist(
email: string,
): Promise<WaitlistJoinResult> {
const normalizedEmail = email.trim().toLowerCase();
const { error } = await getPublicSupabaseClient()
.from("waitlist_signups")
.insert({
email: normalizedEmail,
source: "public_share",
});

if (error?.code === "23505") {
return "already_joined";
}

if (error) {
throw error;
}

return "joined";
}

/**
* Fetches entries for a publicly shared category.
*
Expand Down
3 changes: 0 additions & 3 deletions lib/index.ts

This file was deleted.

76 changes: 74 additions & 2 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,83 @@
import "react-native-url-polyfill/auto";

import {
createClient,
processLock,
type SupportedStorage,
} from "@supabase/supabase-js";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient, processLock } from "@supabase/supabase-js";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
const secureStoreValueLimitBytes = 2048;

/**
* Stores Supabase auth sessions in SecureStore and migrates existing
* AsyncStorage sessions into SecureStore the first time they are read.
*/
const secureStoreAdapter: SupportedStorage = {
getItem: async (key: string) => {
const secureValue = await SecureStore.getItemAsync(key);

if (secureValue != null) {
return secureValue;
}

const legacyValue = await AsyncStorage.getItem(key);

if (legacyValue == null) {
return legacyValue;
}

await SecureStore.setItemAsync(key, legacyValue);
await AsyncStorage.removeItem(key);

return legacyValue;
},
setItem: (key: string, value: string) => {
if (isOverSecureStoreValueLimit(value)) {
console.warn(
"Supabase auth session is larger than SecureStore's 2048-byte Android value limit.",
);
}

return SecureStore.setItemAsync(key, value);
Comment thread
ginnaaph marked this conversation as resolved.
},
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
};

function isOverSecureStoreValueLimit(value: string) {
let bytes = 0;

for (let index = 0; index < value.length; index++) {
const codePoint = value.charCodeAt(index);

if (codePoint >= 0xd800 && codePoint < 0xe000) {
const nextCodePoint = value.charCodeAt(index + 1);

if (
codePoint < 0xdc00 &&
nextCodePoint >= 0xdc00 &&
nextCodePoint < 0xe000
) {
bytes += 4;
index++;
} else {
bytes += 3;
}
} else {
bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3;
}

if (bytes > secureStoreValueLimitBytes) {
return true;
}
}

return false;
}

export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);

Expand All @@ -22,7 +94,7 @@ export function assertSupabaseConfigured() {

export const supabase = createClient(supabaseUrl ?? "", supabaseAnonKey ?? "", {
auth: {
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
...(Platform.OS !== "web" ? { storage: secureStoreAdapter } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
Expand Down
Loading
Loading