Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
22 changes: 12 additions & 10 deletions __tests__/share-waitlist.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,25 @@ const screenSource = readFileSync(
"utf8",
);

test("renders the waitlist capture in both former CTA locations", () => {
assert.equal((screenSource.match(/<WaitlistCapture/g) ?? []).length, 2);
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(
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.

56 changes: 53 additions & 3 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,61 @@
import "react-native-url-polyfill/auto";

import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient, processLock } from "@supabase/supabase-js";
import {
createClient,
processLock,
type SupportedStorage,
} 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;

const secureStoreAdapter: SupportedStorage = {
getItem: (key: string) => SecureStore.getItemAsync(key),
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 +72,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
31 changes: 5 additions & 26 deletions lib/waitlist.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,10 @@ import { readFileSync } from "node:fs";
import test from "node:test";
Comment thread
ginnaaph marked this conversation as resolved.

const apiSource = readFileSync(new URL("./api.ts", import.meta.url), "utf8");
const joinWaitlistSource = apiSource.match(
/export async function joinWaitlist[\s\S]*?(?=\nexport async function)/,
)?.[0];

assert.ok(joinWaitlistSource, "joinWaitlist source should exist");

test("joins the public waitlist with a normalized email and fixed source", () => {
assert.match(
joinWaitlistSource,
/export async function joinWaitlist\(\s*email: string,\s*\): Promise<WaitlistJoinResult>/,
);
assert.match(
joinWaitlistSource,
/const normalizedEmail = email\.trim\(\)\.toLowerCase\(\)/,
);
assert.match(
joinWaitlistSource,
/getPublicSupabaseClient\(\)[\s\S]*\.from\("waitlist_signups"\)[\s\S]*\.insert\(\{[\s\S]*email: normalizedEmail,[\s\S]*source: "public_share",[\s\S]*\}\)/,
);
assert.match(joinWaitlistSource, /return "joined"/);
assert.doesNotMatch(joinWaitlistSource, /\.select\(/);
});

test("maps only unique violations to the already joined result", () => {
assert.match(joinWaitlistSource, /error\?\.code === "23505"/);
assert.match(joinWaitlistSource, /return "already_joined"/);
assert.match(joinWaitlistSource, /throw error/);
test("does not expose an app-level public waitlist API", () => {
assert.doesNotMatch(apiSource, /type WaitlistJoinResult/);
assert.doesNotMatch(apiSource, /export async function joinWaitlist/);
assert.doesNotMatch(apiSource, /\.from\("waitlist_signups"\)/);
assert.doesNotMatch(apiSource, /source: "public_share"/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use semantic absence checks across both cleanup tests.

Both tests rely on formatting-dependent source regexes, allowing equivalent API exports or UI implementations to evade regression coverage.

  • lib/waitlist.test.js#L7-L11: verify actual exports or parse the API AST, including re-exports and alternate literal syntax.
  • __tests__/share-waitlist.test.js#L10-L15: verify rendered CTA/navigation absence or inspect JSX semantically.
  • __tests__/share-waitlist.test.js#L17-L28: verify rendered email-input absence or inspect JSX identifiers and props semantically.
📍 Affects 2 files
  • lib/waitlist.test.js#L7-L11 (this comment)
  • __tests__/share-waitlist.test.js#L10-L15
  • __tests__/share-waitlist.test.js#L17-L28
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/waitlist.test.js` around lines 7 - 11, Replace formatting-dependent
source regex assertions with semantic checks across all three cleanup tests: in
lib/waitlist.test.js, inspect actual API exports or parse the API AST so
re-exports and equivalent literal syntax are covered; in
__tests__/share-waitlist.test.js lines 10-15, verify the rendered CTA/navigation
is absent or inspect JSX semantically; and in lines 17-28, verify the rendered
email input is absent or inspect JSX identifiers and props semantically.

});
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading