Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
92 changes: 75 additions & 17 deletions PRODUCTION_PLAN.md

Large diffs are not rendered by default.

39 changes: 27 additions & 12 deletions app/(auth)/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,35 @@ export default function SignIn() {
const [emailAddress, setEmailAddress] = React.useState("");
const [password, setPassword] = React.useState("");
const [code, setCode] = React.useState("");
const [formError, setFormError] = React.useState<string | null>(null);

const handleSubmit = async () => {
setFormError(null);
const { error } = await signIn.password({
emailAddress,
password,
});
if (error) {
console.error(JSON.stringify(error, null, 2));
// Expected states (wrong password, no such account) — show a friendly
// message instead of throwing a dev error overlay.
const first = (
error as {
errors?: { code?: string; message?: string; longMessage?: string }[];
} | null
)?.errors?.[0];
if (first?.code === "form_identifier_not_found") {
setFormError(
"We couldn't find an account with that email. Sign up below to get started.",
);
} else if (first?.code === "form_password_incorrect") {
setFormError("That password doesn't look right. Please try again.");
} else {
setFormError(
first?.longMessage ||
first?.message ||
"Something went wrong signing in. Please try again.",
);
}
return;
}

Expand Down Expand Up @@ -151,11 +172,6 @@ export default function SignIn() {
}
keyboardType="email-address"
/>
{errors?.fields?.identifier && (
<Text className="auth-error">
{errors.fields.identifier.message}
</Text>
)}
</View>

<View className="auth-field">
Expand All @@ -168,13 +184,12 @@ export default function SignIn() {
secureTextEntry={true}
onChangeText={(password) => setPassword(password)}
/>
{errors?.fields?.password && (
<Text className="auth-error">
{errors.fields.password.message}
</Text>
)}
</View>

{formError && (
<Text className="auth-error">{formError}</Text>
)}

<Pressable
className={`auth-button ${!emailAddress || !password || fetchStatus === "fetching" ? "auth-button-disabled" : ""}`}
onPress={handleSubmit}
Expand All @@ -188,7 +203,7 @@ export default function SignIn() {
</View>

<View className="auth-link-row">
<Text className="auth-link-copy">Don't have an account?</Text>
<Text className="auth-link-copy">Don&apos;t have an account?</Text>
<Link href="/(auth)/sign-up">
<Text className="auth-link">Sign up</Text>
</Link>
Expand Down
10 changes: 5 additions & 5 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { tabs } from "@/constants/data";
import { colors, components } from "@/constants/theme";
import { useAuth } from "@clerk/expo";
import { hasOnboarded } from "@/lib/onboarding";
import { clsx } from "clsx";
import { Redirect, Tabs } from "expo-router";
import { Image, View } from "react-native";
Expand All @@ -17,12 +17,12 @@ const TabIcon = ({ focused, icon }: TabIconProps) => (
);

const TabLayout = () => {
const { isSignedIn, isLoaded } = useAuth();
const insets = useSafeAreaInsets();

if (!isLoaded) return null;
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />;
// Guest-first: no auth wall. Onboarding runs first for everyone; signing in
// is optional (from Settings) and only needed later for Pro/backup.
if (!hasOnboarded()) {
return <Redirect href="/onboarding" />;
}

return (
Expand Down
72 changes: 56 additions & 16 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,71 @@
import AnimatedCounter from "@/components/AnimatedCounter";
import ListHeading from "@/components/ListHeading";
import SubscriptionCard from "@/components/SubscriptionCard";
import SubscriptionFormModal from "@/components/SubscriptionFormModal";
import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard";
import { HOME_USER } from "@/constants/data";
import { icons } from "@/constants/icons";
import images from "@/constants/images";
import { useCurrency } from "@/context/CurrencyContext";
import { useSubscriptions } from "@/context/SubscriptionsContext";
import "@/global.css";
import { priceBucket } from "@/lib/analytics";
import { getDaysUntilRenewal, getMonthlyEquivalent } from "@/lib/billing";
import { success } from "@/lib/haptics";
import { hasSeenNudge, markNudgeSeen } from "@/lib/nudges";
import { formatCurrency } from "@/lib/utils";
import { useClerk, useUser } from "@clerk/expo";
import { styled } from "nativewind";
import { usePostHog } from "posthog-react-native";
import { useMemo, useState } from "react";
import { FlatList, Image, Pressable, Text, View } from "react-native";
import { useEffect, useMemo, useState } from "react";
import { Animated, FlatList, Image, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";

import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context";
const SafeAreaView = styled(RNSafeAreaView) as any;

export default function App() {
const { user } = useUser();
const { user, isSignedIn } = useUser();
const { signOut } = useClerk();
const { subscriptions, addSubscription } = useSubscriptions();
const { baseCurrency } = useCurrency();
const posthog = usePostHog();
const router = useRouter();
const [isCreateModalVisible, setCreateModalVisible] = useState(false);
const [showAddNudge, setShowAddNudge] = useState(
() => !hasSeenNudge("add_first"),
);

const activeSubscriptions = useMemo(
() => subscriptions.filter((sub) => sub.status === "active"),
[subscriptions],
);

// One-time first-run nudge: gently pulse the "+" until the first sub is added.
const [addPulse] = useState(() => new Animated.Value(1));
const nudgeActive = showAddNudge && activeSubscriptions.length === 0;
useEffect(() => {
if (!nudgeActive) {
addPulse.setValue(1);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(addPulse, {
toValue: 1.15,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(addPulse, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]),
);
loop.start();
return () => loop.stop();
}, [nudgeActive, addPulse]);

// Real monthly outflow across mixed billing cycles (annual/quarterly/etc.
// are normalized to a per-month average, so the total reflects everything).
const monthlyTotal = useMemo(
Expand Down Expand Up @@ -85,6 +116,11 @@ export default function App() {

const handleCreate = (draft: SubscriptionDraft) => {
const created = addSubscription(draft);
success();
if (showAddNudge) {
markNudgeSeen("add_first");
setShowAddNudge(false);
}
// Non-identifying signal only: no name, no exact price, no currency.
posthog.capture("subscription_created", {
subscription_id: created.id,
Expand All @@ -104,7 +140,7 @@ export default function App() {
const displayName =
user?.firstName ||
user?.emailAddresses[0]?.emailAddress?.split("@")[0] ||
HOME_USER.name;
"Guest";

return (
<SafeAreaView className="flex-1 bg-background p-5">
Expand All @@ -124,15 +160,19 @@ export default function App() {
/>
<View>
<Text className="home-user-name">{displayName}</Text>
<Pressable onPress={() => signOut()} className="ml-4 mt-1">
<Text className="text-sm font-sans-medium text-destructive">
Sign out
</Text>
</Pressable>
{isSignedIn && (
<Pressable onPress={() => signOut()} className="ml-4 mt-1">
<Text className="text-sm font-sans-medium text-destructive">
Sign out
</Text>
</Pressable>
)}
</View>
</View>
<Pressable onPress={() => setCreateModalVisible(true)}>
<Image source={icons.add} className="home-add-icon" />
<Animated.View style={{ transform: [{ scale: addPulse }] }}>
<Image source={icons.add} className="home-add-icon" />
</Animated.View>
</Pressable>
</View>

Expand All @@ -146,13 +186,13 @@ export default function App() {
</View>
</View>

<Text
<AnimatedCounter
value={monthlyTotal}
currency={baseCurrency}
className="home-hero-amount"
numberOfLines={1}
adjustsFontSizeToFit
>
{formatCurrency(monthlyTotal, baseCurrency)}
</Text>
/>
<Text className="home-hero-year">
≈ {formatCurrency(yearlyTotal, baseCurrency)} / year
</Text>
Expand Down Expand Up @@ -210,7 +250,7 @@ export default function App() {
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<Text className="home-empty-state">
No subscriptions yet — tap + to add your first one.
No subscriptions yet — tap + and I&apos;ll do the math.
</Text>
}
contentContainerClassName="pb-30"
Expand Down
72 changes: 61 additions & 11 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import images from "@/constants/images";
import { getKv, setKv } from "@/db/subscriptionsRepo";
import { ANALYTICS_OPTOUT_KEY } from "@/lib/analytics";
import * as notifications from "@/lib/notifications";
import { resetOnboarding } from "@/lib/onboarding";
import { useClerk, useUser } from "@clerk/expo";
import dayjs from "dayjs";
import { useRouter } from "expo-router";
import { styled } from "nativewind";
import { usePostHog } from "posthog-react-native";
import { useState } from "react";
Expand All @@ -23,10 +25,11 @@ const CURRENCY_ITEMS: PickerItem[] = CURRENCY_CODES.map((code) => ({
const SafeAreaView = styled(RNSafeAreaView) as any;
const Settings = () => {
const { signOut } = useClerk();
const { user } = useUser();
const { user, isSignedIn } = useUser();
const { baseCurrency, setBaseCurrency } = useCurrency();
const { subscriptions } = useSubscriptions();
const { subscriptions, clearAllData } = useSubscriptions();
const posthog = usePostHog();
const router = useRouter();
const [showCurrencyPicker, setShowCurrencyPicker] = useState(false);
const [remindersOn, setRemindersOn] = useState(() =>
notifications.remindersEnabled(),
Expand Down Expand Up @@ -58,9 +61,17 @@ const Settings = () => {
}
};

const displayName = user?.firstName || user?.fullName || user?.emailAddresses[0]?.emailAddress?.split("@")[0] || "User";
const email = user?.emailAddresses[0]?.emailAddress || "No email";
const memberSince = user?.createdAt ? dayjs(user.createdAt).format("MMMM D, YYYY") : "Recently";
const displayName =
user?.firstName ||
user?.fullName ||
user?.emailAddresses[0]?.emailAddress?.split("@")[0] ||
"Guest";
const email = isSignedIn
? (user?.emailAddresses[0]?.emailAddress ?? "No email")
: "Not signed in";
const memberSince = user?.createdAt
? dayjs(user.createdAt).format("MMMM D, YYYY")
: "Recently";

return (
<SafeAreaView className="flex-1 bg-background">
Expand Down Expand Up @@ -143,13 +154,52 @@ const Settings = () => {
</View>

<View className="mt-10">
<Pressable
onPress={() => signOut()}
className="auth-button"
>
<Text className="auth-button-text">Sign out</Text>
</Pressable>
{isSignedIn ? (
<Pressable onPress={() => signOut()} className="auth-button">
<Text className="auth-button-text">Sign out</Text>
</Pressable>
) : (
<Pressable
onPress={() => router.push("/(auth)/sign-in")}
className="auth-button"
>
<Text className="auth-button-text">Sign in</Text>
</Pressable>
)}
{!isSignedIn && (
<Text className="mt-3 text-center text-xs font-sans-medium text-muted-foreground">
Sign in to back up your subscriptions and sync across devices.
</Text>
)}
</View>

{__DEV__ && (
<View className="mt-6 gap-1">
<Pressable
className="items-center py-2"
onPress={() => {
resetOnboarding();
router.replace("/onboarding");
}}
>
<Text className="text-xs font-sans-semibold text-muted-foreground">
Reset onboarding (dev)
</Text>
</Pressable>
<Pressable
className="items-center py-2"
onPress={() => {
clearAllData();
resetOnboarding();
router.replace("/onboarding");
}}
>
<Text className="text-xs font-sans-semibold text-destructive">
Clear all data (dev)
</Text>
</Pressable>
</View>
)}
</ScrollView>

<PickerSheet
Expand Down
28 changes: 16 additions & 12 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,23 @@ function RootLayoutContent() {
const { isLoaded: authLoaded } = useAuth();
const router = useRouter();

// Tapping a reminder deep-links to that subscription (foreground + cold start).
// Tapping a reminder deep-links to that subscription. Only the live listener
// (fires while the app is running, navigator mounted) — navigation is
// deferred a tick so we never push before the router is ready.
useEffect(() => {
const openFromResponse = (
response: Notifications.NotificationResponse | null,
) => {
const id = response?.notification.request.content.data?.subscriptionId;
if (typeof id === "string") {
router.push(`/subscriptions/${id}`);
}
let active = true;
const sub = Notifications.addNotificationResponseReceivedListener(
(response) => {
const id = response?.notification.request.content.data?.subscriptionId;
if (active && typeof id === "string") {
setTimeout(() => router.push(`/subscriptions/${id}`), 0);
}
},
);
return () => {
active = false;
sub.remove();
};
Notifications.getLastNotificationResponseAsync().then(openFromResponse);
const sub =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
return () => sub.remove();
}, [router]);

const [fontsLoaded, fontError] = useFonts({
Expand Down Expand Up @@ -112,6 +115,7 @@ function RootLayoutContent() {
}}
>
<Stack.Screen name="(tabs)" options={{ animation: "fade" }} />
<Stack.Screen name="onboarding" options={{ animation: "fade" }} />
<Stack.Screen name="subscriptions/[id]" />
</Stack>
);
Expand Down
Loading