diff --git a/app.json b/app.json index 3d5095e..ef3c305 100644 --- a/app.json +++ b/app.json @@ -38,6 +38,19 @@ "backgroundColor": "#000000" } } + ], + [ + "expo-font", + { + "fonts": [ + "./assets/fonts/PlusJakartaSans-Bold.ttf", + "./assets/fonts/PlusJakartaSans-ExtraBold.ttf", + "./assets/fonts/PlusJakartaSans-Light.ttf", + "./assets/fonts/PlusJakartaSans-Medium.ttf", + "./assets/fonts/PlusJakartaSans-Regular.ttf", + "./assets/fonts/PlusJakartaSans-SemiBold.ttf" + ] + } ] ], "experiments": { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 67e9bda..7fdba70 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,37 +1,99 @@ +import ListHeading from "@/components/ListHeading"; +import SubscriptionCard from "@/components/SubscriptionCard"; +import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard"; +import { + HOME_BALANCE, + HOME_SUBSCRIPTIONS, + HOME_USER, + UPCOMING_SUBSCRIPTIONS, +} from "@/constants/data"; +import icons from "@/constants/icons"; +import images from "@/constants/images"; import "@/global.css"; -import { Link } from "expo-router"; +import { formatCurrency } from "@/lib/utils/currency"; +import dayjs from "dayjs"; +import { useRouter } from "expo-router"; import { styled } from "nativewind"; -import { Text } from "react-native"; +import { useState } from "react"; +import { FlatList, Image, Text, View } from "react-native"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView); export default function App() { + const router = useRouter(); + const [expandedSubscriptionId, setExpandedSubscriptionId] = useState< + string | null + >(null); + return ( - - Welcome to Nativewind! - - - Sign In - - - Sign Up - + ( + <> + + + + {HOME_USER.name} + + + + + + Balance + + + + {formatCurrency(HOME_BALANCE.amount)} + + + {dayjs(HOME_BALANCE.nextRenewalDate).format("MM/DD")} + + + + + + router.push("/(tabs)/subscriptions")} /> + ( + + )} + keyExtractor={(item) => item.id} + horizontal + showsHorizontalScrollIndicator={false} + ListEmptyComponent={ + + No upcoming subscriptions yet. + + } + /> + - {/* Subscription Examples */} - - Claude Max Subscription - + router.push("/(tabs)/subscriptions")} /> + + )} + data={HOME_SUBSCRIPTIONS} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + { + setExpandedSubscriptionId((currentId) => + currentId === item.id ? null : item.id, + ); + }} + /> + )} + extraData={expandedSubscriptionId} + ItemSeparatorComponent={() => } + ListEmptyComponent={ + No subscriptions yet. + } + contentContainerClassName="pb-28" + /> ); } diff --git a/app/subscriptions/[id].tsx b/app/(tabs)/subscriptions/[id].tsx similarity index 59% rename from app/subscriptions/[id].tsx rename to app/(tabs)/subscriptions/[id].tsx index 9db5de1..480d192 100644 --- a/app/subscriptions/[id].tsx +++ b/app/(tabs)/subscriptions/[id].tsx @@ -1,17 +1,21 @@ import { Link, useLocalSearchParams } from "expo-router"; +import { styled } from "nativewind"; import React from "react"; -import { Text, View } from "react-native"; +import { Text } from "react-native"; +import { SafeAreaView as RNSafeArea } from "react-native-safe-area-context"; + +const SafeAreaView = styled(RNSafeArea); const SubscriptionDetails = () => { const { id } = useLocalSearchParams<{ id: string }>(); return ( - + Subscription Details: {id} {" "} Go Back{" "} - + ); }; diff --git a/app/_layout.tsx b/app/_layout.tsx index 1eb8d42..7897504 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,8 +1,29 @@ import "@/global.css"; -import { Stack } from "expo-router"; +import { useFonts } from "expo-font"; +import { SplashScreen, Stack } from "expo-router"; +import { useEffect } from "react"; import { StatusBar } from "react-native"; +SplashScreen.preventAutoHideAsync(); + export default function RootLayout() { + const [fontsLoaded, fontsError] = useFonts({ + "sans-light": require("../assets/fonts/PlusJakartaSans-Light.ttf"), + "sans-regular": require("../assets/fonts/PlusJakartaSans-Regular.ttf"), + "sans-medium": require("../assets/fonts/PlusJakartaSans-Medium.ttf"), + "sans-semibold": require("../assets/fonts/PlusJakartaSans-SemiBold.ttf"), + "sans-bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"), + "sans-extrabold": require("../assets/fonts/PlusJakartaSans-ExtraBold.ttf"), + }); + + useEffect(() => { + if (fontsLoaded || fontsError) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded, fontsError]); + + if (!fontsLoaded && !fontsError) return null; + return ( <> diff --git a/assets/images/avatar.png b/assets/images/avatar.png index f841c71..6ff9daa 100644 Binary files a/assets/images/avatar.png and b/assets/images/avatar.png differ diff --git a/components/ListHeading.tsx b/components/ListHeading.tsx new file mode 100644 index 0000000..0b79c90 --- /dev/null +++ b/components/ListHeading.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Text, TouchableOpacity, View } from "react-native"; + +const ListHeading = ({ title, onPress }: ListHeadingProps) => { + return ( + + {title} + + + View All + + + ); +}; + +export default ListHeading; diff --git a/components/SubscriptionCard.tsx b/components/SubscriptionCard.tsx new file mode 100644 index 0000000..fa0bc19 --- /dev/null +++ b/components/SubscriptionCard.tsx @@ -0,0 +1,127 @@ +import { formatCurrency } from "@/lib/utils/currency"; +import { formatSubscriptionDateTime } from "@/lib/utils/date"; +import { formatStatusLabel } from "@/lib/utils/status"; +import clsx from "clsx"; +import React from "react"; +import { Image, Pressable, Text, View } from "react-native"; + +const SubscriptionCard = ({ + name, + price, + currency, + icon, + billing, + color, + category, + plan, + paymentMethod, + renewalDate, + startDate, + status, + expanded, + onPress, +}: SubscriptionCardProps) => { + return ( + + + + + + + {name} + + + {category?.trim() || + plan?.trim() || + formatSubscriptionDateTime(renewalDate)} + + + + + + + {formatCurrency(price, currency)} + + {billing} + + + + {expanded && ( + + + + + Payment: + + {paymentMethod?.trim()} + + + + + + + Category: + + {category?.trim() || plan?.trim()} + + + + + + + Started: + + {startDate ? formatSubscriptionDateTime(startDate) : ""} + + + + + + + Renewal Date: + + {renewalDate ? formatSubscriptionDateTime(renewalDate) : ""} + + + + + + + Status: + + {status ? formatStatusLabel(status) : ""} + + + + + + )} + + ); +}; + +export default SubscriptionCard; diff --git a/components/UpcomingSubscriptionCard.tsx b/components/UpcomingSubscriptionCard.tsx new file mode 100644 index 0000000..70de3d7 --- /dev/null +++ b/components/UpcomingSubscriptionCard.tsx @@ -0,0 +1,31 @@ +import { formatCurrency } from "@/lib/utils/currency"; +import React from "react"; +import { Image, Text, View } from "react-native"; + +const UpcomingSubscriptionCard = ({ + name, + price, + daysLeft, + icon, + currency, +}: UpcomingSubscriptionCardProps) => { + return ( + + + + + {formatCurrency(price, currency)} + + {daysLeft} {daysLeft > 1 ? "days" : "day"} left + + + + + + {name} + + + ); +}; + +export default UpcomingSubscriptionCard; diff --git a/constants/data.ts b/constants/data.ts index 70fa413..3f59690 100644 --- a/constants/data.ts +++ b/constants/data.ts @@ -1,8 +1,107 @@ import icons from "./icons"; -export const tabs = [ +export const tabs: AppTab[] = [ { name: "index", title: "Home", icon: icons.home }, { name: "subscriptions", title: "Subscriptions", icon: icons.wallet }, { name: "insights", title: "Insights", icon: icons.activity }, { name: "settings", title: "Settings", icon: icons.setting }, ]; + +export const HOME_USER = { + name: "Ken Cedrick", +}; + +export const HOME_BALANCE = { + amount: 19972.48, + nextRenewalDate: "2026-08-15T09:00:00.000Z", +}; + +export const UPCOMING_SUBSCRIPTIONS: UpcomingSubscription[] = [ + { + id: "spotify", + icon: icons.spotify, + name: "Spotify", + price: 5.99, + currency: "USD", + daysLeft: 2, + }, + { + id: "notion", + icon: icons.notion, + name: "Notion", + price: 12.0, + currency: "USD", + daysLeft: 4, + }, + { + id: "figma", + icon: icons.figma, + name: "Figma", + price: 15.0, + currency: "USD", + daysLeft: 6, + }, +]; + +export const HOME_SUBSCRIPTIONS: Subscription[] = [ + { + id: "adobe-creative-cloud", + icon: icons.adobe, + name: "Adobe Creative Cloud", + plan: "Teams Plan", + category: "Design", + paymentMethod: "Visa ending in 8530", + status: "active", + startDate: "2025-03-20T10:00:00.000Z", + price: 77.49, + currency: "USD", + billing: "Monthly", + renewalDate: "2026-03-20T10:00:00.000Z", + color: "#f5c542", + }, + { + id: "github-pro", + icon: icons.github, + name: "GitHub Pro", + plan: "Developer", + category: "Developer Tools", + paymentMethod: "Mastercard ending in 2408", + status: "active", + startDate: "2024-11-24T10:00:00.000Z", + price: 9.99, + currency: "USD", + billing: "Monthly", + renewalDate: "2026-03-24T10:00:00.000Z", + color: "#e8def8", + }, + { + id: "claude-pro", + icon: icons.claude, + name: "Claude Pro", + plan: "Pro Plan", + category: "AI Tools", + paymentMethod: "Amex ending in 1010", + status: "paused", + startDate: "2025-06-27T10:00:00.000Z", + price: 20.0, + currency: "USD", + billing: "Monthly", + renewalDate: "2026-03-27T10:00:00.000Z", + color: "#b8d4e3", + }, + { + id: "canva-pro", + icon: icons.canva, + name: "Canva Pro", + plan: "Yearly Access", + category: "Design", + paymentMethod: "Visa ending in 7784", + status: "cancelled", + startDate: "2024-04-02T10:00:00.000Z", + price: 119.99, + currency: "USD", + billing: "Yearly", + renewalDate: "2026-04-02T10:00:00.000Z", + color: "#b8e8d0", + }, +]; diff --git a/constants/images.ts b/constants/images.ts new file mode 100644 index 0000000..1d8f5f7 --- /dev/null +++ b/constants/images.ts @@ -0,0 +1,7 @@ +import avatar from "@/assets/images/avatar.png"; +import splashpattern from "@/assets/images/splash-pattern.png"; + +export default { + splashpattern, + avatar, +}; diff --git a/global.css b/global.css index 2218b1a..4a69400 100644 --- a/global.css +++ b/global.css @@ -5,416 +5,416 @@ @import "nativewind/theme"; @theme { - --color-background: #fff9e3; - --color-foreground: #081126; - --color-card: #fff8e7; - --color-muted: #f6eecf; - --color-muted-foreground: rgba(0, 0, 0, 0.6); - --color-primary: #081126; - --color-accent: #ea7a53; - --color-border: rgba(0, 0, 0, 0.1); - --color-success: #16a34a; - --color-destructive: #dc2626; - --color-subscription: #8fd1bd; - - --spacing-0: 0px; - --spacing-1: 4px; - --spacing-2: 8px; - --spacing-3: 12px; - --spacing-4: 16px; - --spacing-5: 20px; - --spacing-6: 24px; - --spacing-7: 28px; - --spacing-8: 32px; - --spacing-9: 36px; - --spacing-10: 40px; - --spacing-11: 44px; - --spacing-12: 48px; - --spacing-14: 56px; - --spacing-16: 64px; - --spacing-18: 72px; - --spacing-20: 80px; - --spacing-24: 96px; - --spacing-30: 120px; - - --font-sans: sans-regular; - --font-sans-light: sans-light; - --font-sans-medium: sans-medium; - --font-sans-semibold: sans-semibold; - --font-sans-bold: sans-bold; - --font-sans-extrabold: sans-extrabold; + --color-background: #fff9e3; + --color-foreground: #081126; + --color-card: #fff8e7; + --color-muted: #f6eecf; + --color-muted-foreground: rgba(0, 0, 0, 0.6); + --color-primary: #081126; + --color-accent: #ea7a53; + --color-border: rgba(0, 0, 0, 0.1); + --color-success: #16a34a; + --color-destructive: #dc2626; + --color-subscription: #8fd1bd; + + --spacing-0: 0px; + --spacing-1: 4px; + --spacing-2: 8px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-5: 20px; + --spacing-6: 24px; + --spacing-7: 28px; + --spacing-8: 32px; + --spacing-9: 36px; + --spacing-10: 40px; + --spacing-11: 44px; + --spacing-12: 48px; + --spacing-14: 56px; + --spacing-16: 64px; + --spacing-18: 72px; + --spacing-20: 80px; + --spacing-24: 96px; + --spacing-30: 120px; + + --font-sans: sans-regular; + --font-sans-light: sans-light; + --font-sans-medium: sans-medium; + --font-sans-semibold: sans-semibold; + --font-sans-bold: sans-bold; + --font-sans-extrabold: sans-extrabold; } @layer components { - .tabs-icon { - @apply size-12 items-center justify-center; - } + .tabs-icon { + @apply size-12 items-center justify-center; + } - .tabs-pill { - @apply size-12 items-center justify-center rounded-full bg-transparent; - } + .tabs-pill { + @apply size-12 items-center justify-center rounded-full bg-transparent; + } - .tabs-active { - @apply bg-accent; - } + .tabs-active { + @apply bg-accent; + } - .tabs-glyph { - @apply size-6; - } + .tabs-glyph { + @apply size-6; + } - .home-header { - @apply mb-2.5 flex-row items-center justify-between; - } + .home-header { + @apply mb-2.5 flex-row items-center justify-between; + } - .home-user { - @apply flex-row items-center; - } + .home-user { + @apply flex-row items-center; + } - .home-avatar { - @apply size-16 rounded-full; - } + .home-avatar { + @apply size-16 rounded-full; + } - .home-user-name { - @apply ml-4 text-2xl font-sans-bold text-primary; - } + .home-user-name { + @apply ml-4 text-2xl font-sans-bold text-primary; + } - .home-add-icon { - @apply size-12; - } + .home-add-icon { + @apply size-8; + } - .home-balance-card { - @apply my-2.5 min-h-50 justify-between gap-5 rounded-bl-4xl rounded-tr-4xl bg-accent p-6; - } + .home-balance-card { + @apply my-2.5 min-h-50 justify-between gap-5 rounded-bl-4xl rounded-tr-4xl bg-accent p-6; + } - .home-balance-label { - @apply text-xl font-sans-semibold text-white/80; - } + .home-balance-label { + @apply text-xl font-sans-semibold text-white/80; + } - .home-balance-row { - @apply flex-row items-center justify-between; - } + .home-balance-row { + @apply flex-row items-center justify-between; + } - .home-balance-amount { - @apply text-4xl font-sans-extrabold text-white; - } + .home-balance-amount { + @apply text-4xl font-sans-extrabold text-white; + } - .home-balance-date { - @apply text-xl font-sans-medium text-white; - } + .home-balance-date { + @apply text-xl font-sans-medium text-white; + } - .list-head { - @apply my-5 flex-row items-center justify-between; - } + .list-head { + @apply my-5 flex-row items-center justify-between; + } - .list-title { - @apply text-2xl font-sans-bold text-primary; - } + .list-title { + @apply text-2xl font-sans-bold text-primary; + } - .list-action { - @apply rounded-full border border-black/20 px-4 py-1; - } + .list-action { + @apply rounded-full border border-black/20 px-4 py-1; + } - .list-action-text { - @apply text-lg font-sans-semibold text-primary; - } + .list-action-text { + @apply text-lg font-sans-semibold text-primary; + } - .upcoming-card { - @apply mr-4 w-44 rounded-2xl border border-black/10 bg-background p-4; - } + .upcoming-card { + @apply mr-4 w-44 rounded-2xl border border-black/10 bg-background p-4; + } - .upcoming-row { - @apply flex-row items-center gap-3; - } + .upcoming-row { + @apply flex-row items-center gap-3; + } - .upcoming-icon { - @apply size-14; - } + .upcoming-icon { + @apply size-14; + } - .upcoming-price { - @apply text-lg font-sans-bold text-primary; - } + .upcoming-price { + @apply text-lg font-sans-bold text-primary; + } - .upcoming-meta { - @apply text-sm font-sans-semibold text-muted-foreground; - } + .upcoming-meta { + @apply text-sm font-sans-semibold text-muted-foreground; + } - .upcoming-name { - @apply mt-2 text-lg font-sans-bold text-primary; - } + .upcoming-name { + @apply mt-2 text-lg font-sans-bold text-primary; + } - .sub-card { - @apply rounded-2xl border border-border p-4; - } + .sub-card { + @apply rounded-2xl border border-border p-4; + } - .sub-card-expanded { - @apply bg-subscription; - } + .sub-card-expanded { + @apply bg-subscription; + } - .sub-head { - @apply flex-row items-center py-2; - } + .sub-head { + @apply flex-row items-center py-2; + } - .sub-main { - @apply min-w-0 flex-1 flex-row items-center gap-3; - } + .sub-main { + @apply min-w-0 flex-1 flex-row items-center gap-3; + } - .sub-icon { - @apply size-16 rounded-lg; - } + .sub-icon { + @apply size-16 rounded-lg; + } - .sub-copy { - @apply min-w-0 flex-1; - } + .sub-copy { + @apply min-w-0 flex-1; + } - .sub-title { - @apply mb-1 text-lg font-sans-bold text-primary; - } + .sub-title { + @apply mb-1 text-lg font-sans-bold text-primary; + } - .sub-meta { - @apply text-sm font-sans-semibold text-muted-foreground; - } + .sub-meta { + @apply text-sm font-sans-semibold text-muted-foreground; + } - .sub-price-box { - @apply ml-3 shrink-0 items-end; - } + .sub-price-box { + @apply ml-3 shrink-0 items-end; + } - .sub-price { - @apply mb-1 text-lg font-sans-bold text-primary; - } + .sub-price { + @apply mb-1 text-lg font-sans-bold text-primary; + } - .sub-billing { - @apply text-sm font-sans-medium text-muted-foreground; - } + .sub-billing { + @apply text-sm font-sans-medium text-muted-foreground; + } - .sub-body { - @apply mt-6 gap-4; - } + .sub-body { + @apply mt-6 gap-4; + } - .sub-details { - @apply gap-6; - } + .sub-details { + @apply gap-6; + } - .sub-row { - @apply flex-row items-center justify-between gap-3; - } + .sub-row { + @apply flex-row items-center justify-between gap-3; + } - .sub-row-copy { - @apply min-w-0 flex-1 flex-row items-center gap-2; - } + .sub-row-copy { + @apply min-w-0 flex-1 flex-row items-center gap-2; + } - .sub-label { - @apply shrink-0 text-base font-sans-medium text-muted-foreground; - } + .sub-label { + @apply shrink-0 text-base font-sans-medium text-muted-foreground; + } - .sub-value { - @apply flex-1 font-sans-bold text-primary; - } + .sub-value { + @apply flex-1 font-sans-bold text-primary; + } - .sub-cancel { - @apply mt-2 items-center rounded-full bg-primary py-4; - } + .sub-cancel { + @apply mt-2 items-center rounded-full bg-primary py-4; + } - .sub-cancel-disabled { - @apply bg-primary/35; - } + .sub-cancel-disabled { + @apply bg-primary/35; + } - .sub-cancel-text { - @apply font-sans-bold text-background; - } + .sub-cancel-text { + @apply font-sans-bold text-background; + } - .home-empty-state { - @apply py-4 text-sm font-sans-medium text-black/60; - } + .home-empty-state { + @apply py-4 text-sm font-sans-medium text-black/60; + } - .auth-safe-area { - @apply flex-1 bg-background; - } + .auth-safe-area { + @apply flex-1 bg-background; + } - .auth-screen { - @apply flex-1 bg-background; - } + .auth-screen { + @apply flex-1 bg-background; + } - .auth-scroll { - @apply flex-1; - } + .auth-scroll { + @apply flex-1; + } - .auth-content { - @apply grow px-5 pb-10 pt-8; - } + .auth-content { + @apply grow px-5 pb-10 pt-8; + } - .auth-brand-block { - @apply mt-2 items-center; - } + .auth-brand-block { + @apply mt-2 items-center; + } - .auth-logo-wrap { - @apply mb-7 flex-row items-center gap-3; - } + .auth-logo-wrap { + @apply mb-7 flex-row items-center gap-3; + } - .auth-logo-mark { - @apply relative size-14 items-center justify-center rounded-2xl bg-accent; - } + .auth-logo-mark { + @apply relative size-14 items-center justify-center rounded-2xl bg-accent; + } - .auth-logo-mark-text { - @apply text-2xl font-sans-extrabold text-background; - } + .auth-logo-mark-text { + @apply text-2xl font-sans-extrabold text-background; + } - .auth-wordmark { - @apply text-3xl font-sans-extrabold text-primary; - } + .auth-wordmark { + @apply text-3xl font-sans-extrabold text-primary; + } - .auth-wordmark-sub { - @apply -mt-1 text-xs font-sans-semibold uppercase tracking-[1px] text-muted-foreground; - } + .auth-wordmark-sub { + @apply -mt-1 text-xs font-sans-semibold uppercase tracking-[1px] text-muted-foreground; + } - .auth-title { - @apply text-3xl font-sans-bold text-primary; - } + .auth-title { + @apply text-3xl font-sans-bold text-primary; + } - .auth-subtitle { - @apply mt-2 max-w-[320px] text-center text-base font-sans-medium text-muted-foreground; - } + .auth-subtitle { + @apply mt-2 max-w-[320px] text-center text-base font-sans-medium text-muted-foreground; + } - .auth-card { - @apply mt-8 rounded-3xl border border-border bg-card p-5; - } + .auth-card { + @apply mt-8 rounded-3xl border border-border bg-card p-5; + } - .auth-form { - @apply gap-4; - } + .auth-form { + @apply gap-4; + } - .auth-field { - @apply gap-2; - } + .auth-field { + @apply gap-2; + } - .auth-label { - @apply text-sm font-sans-semibold text-primary; - } + .auth-label { + @apply text-sm font-sans-semibold text-primary; + } - .auth-input { - @apply rounded-2xl border border-border bg-background px-4 py-4 text-base font-sans-medium text-primary; - } + .auth-input { + @apply rounded-2xl border border-border bg-background px-4 py-4 text-base font-sans-medium text-primary; + } - .auth-input-error { - @apply border-destructive; - } + .auth-input-error { + @apply border-destructive; + } - .auth-error { - @apply text-xs font-sans-medium text-destructive; - } + .auth-error { + @apply text-xs font-sans-medium text-destructive; + } - .auth-helper { - @apply text-sm font-sans-medium text-muted-foreground; - } + .auth-helper { + @apply text-sm font-sans-medium text-muted-foreground; + } - .auth-button { - @apply mt-1 items-center rounded-2xl bg-accent py-4; - } + .auth-button { + @apply mt-1 items-center rounded-2xl bg-accent py-4; + } - .auth-button-disabled { - @apply bg-accent/45; - } + .auth-button-disabled { + @apply bg-accent/45; + } - .auth-button-text { - @apply text-base font-sans-bold text-primary; - } + .auth-button-text { + @apply text-base font-sans-bold text-primary; + } - .auth-secondary-button { - @apply items-center rounded-2xl border border-accent/30 bg-accent/10 py-3; - } + .auth-secondary-button { + @apply items-center rounded-2xl border border-accent/30 bg-accent/10 py-3; + } - .auth-secondary-button-text { - @apply text-sm font-sans-semibold text-accent; - } + .auth-secondary-button-text { + @apply text-sm font-sans-semibold text-accent; + } - .auth-divider-row { - @apply my-4 flex-row items-center gap-3; - } + .auth-divider-row { + @apply my-4 flex-row items-center gap-3; + } - .auth-divider-line { - @apply h-px flex-1 bg-border; - } + .auth-divider-line { + @apply h-px flex-1 bg-border; + } - .auth-divider-text { - @apply text-[11px] font-sans-semibold uppercase tracking-[1px] text-muted-foreground; - } + .auth-divider-text { + @apply text-[11px] font-sans-semibold uppercase tracking-[1px] text-muted-foreground; + } - .auth-link-row { - @apply mt-5 flex-row items-center justify-center gap-1; - } + .auth-link-row { + @apply mt-5 flex-row items-center justify-center gap-1; + } - .auth-link-copy { - @apply text-sm font-sans-medium text-muted-foreground; - } + .auth-link-copy { + @apply text-sm font-sans-medium text-muted-foreground; + } - .auth-link { - @apply text-sm font-sans-bold text-accent; - } + .auth-link { + @apply text-sm font-sans-bold text-accent; + } - .modal-overlay { - @apply flex-1 bg-black/50; - } + .modal-overlay { + @apply flex-1 bg-black/50; + } - .modal-container { - @apply mt-auto max-h-[85%] rounded-t-3xl bg-background; - } + .modal-container { + @apply mt-auto max-h-[85%] rounded-t-3xl bg-background; + } - .modal-header { - @apply flex-row items-center justify-between border-b border-border px-5 py-4; - } + .modal-header { + @apply flex-row items-center justify-between border-b border-border px-5 py-4; + } - .modal-title { - @apply text-xl font-sans-bold text-primary; - } + .modal-title { + @apply text-xl font-sans-bold text-primary; + } - .modal-close { - @apply size-8 items-center justify-center rounded-full bg-muted; - } + .modal-close { + @apply size-8 items-center justify-center rounded-full bg-muted; + } - .modal-close-text { - @apply text-lg font-sans-bold text-primary; - } + .modal-close-text { + @apply text-lg font-sans-bold text-primary; + } - .modal-body { - @apply gap-5 p-5; - } + .modal-body { + @apply gap-5 p-5; + } - .picker-row { - @apply flex-row gap-3; - } + .picker-row { + @apply flex-row gap-3; + } - .picker-option { - @apply flex-1 items-center rounded-2xl border border-border bg-background py-3; - } + .picker-option { + @apply flex-1 items-center rounded-2xl border border-border bg-background py-3; + } - .picker-option-active { - @apply border-accent bg-accent/10; - } + .picker-option-active { + @apply border-accent bg-accent/10; + } - .picker-option-text { - @apply text-sm font-sans-semibold text-muted-foreground; - } + .picker-option-text { + @apply text-sm font-sans-semibold text-muted-foreground; + } - .picker-option-text-active { - @apply text-accent; - } + .picker-option-text-active { + @apply text-accent; + } - .category-scroll { - @apply flex-row flex-wrap gap-2; - } + .category-scroll { + @apply flex-row flex-wrap gap-2; + } - .category-chip { - @apply rounded-full border border-border bg-background px-4 py-2; - } + .category-chip { + @apply rounded-full border border-border bg-background px-4 py-2; + } - .category-chip-active { - @apply border-accent bg-accent/10; - } + .category-chip-active { + @apply border-accent bg-accent/10; + } - .category-chip-text { - @apply text-sm font-sans-semibold text-muted-foreground; - } + .category-chip-text { + @apply text-sm font-sans-semibold text-muted-foreground; + } - .category-chip-text-active { - @apply text-accent; - } -} \ No newline at end of file + .category-chip-text-active { + @apply text-accent; + } +} diff --git a/lib/utils/currency.ts b/lib/utils/currency.ts new file mode 100644 index 0000000..8d41cb8 --- /dev/null +++ b/lib/utils/currency.ts @@ -0,0 +1,60 @@ +const API_URL = "https://api.frankfurter.app/latest"; + +const STATIC_RATES: Record = { + USD: 0.017, + EUR: 0.016, + GBP: 0.014, + JPY: 2.63, + AUD: 0.026, + CAD: 0.024, + SGD: 0.023, +}; + +let cachedRates: Record | null = null; +let lastFetched = 0; +const CACHE_TTL = 30 * 60 * 1000; + +async function fetchRates(): Promise> { + const res = await fetch(`${API_URL}?from=PHP`); + const data = await res.json(); + return data.rates as Record; +} + +export async function initCurrencyRates(): Promise { + try { + cachedRates = await fetchRates(); + lastFetched = Date.now(); + } catch { + cachedRates = STATIC_RATES; + } +} + +function getRate(fromCurrency: string): number { + const now = Date.now(); + if (!cachedRates || now - lastFetched > CACHE_TTL) { + return STATIC_RATES[fromCurrency] ?? 1; + } + return cachedRates[fromCurrency] ?? STATIC_RATES[fromCurrency] ?? 1; +} + +export function formatCurrency( + value: number, + fromCurrency: string = "PHP", +): string { + try { + let phpValue = value; + + if (fromCurrency !== "PHP") { + phpValue = value / getRate(fromCurrency); + } + + return new Intl.NumberFormat("en-PH", { + style: "currency", + currency: "PHP", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(phpValue); + } catch { + return `₱${value.toFixed(2)}`; + } +} diff --git a/lib/utils/date.ts b/lib/utils/date.ts new file mode 100644 index 0000000..c4a4815 --- /dev/null +++ b/lib/utils/date.ts @@ -0,0 +1,9 @@ +import dayjs from "dayjs"; + +export const formatSubscriptionDateTime = (value?: string): string => { + if (!value) return "Not provided"; + const parsedDate = dayjs(value); + return parsedDate.isValid() + ? parsedDate.format("MM/DD/YYYY") + : "Not provided"; +}; diff --git a/lib/utils/status.ts b/lib/utils/status.ts new file mode 100644 index 0000000..7afb0df --- /dev/null +++ b/lib/utils/status.ts @@ -0,0 +1,4 @@ +export const formatStatusLabel = (value?: string): string => { + if (!value) return "Unknown"; + return value.charAt(0).toUpperCase() + value.slice(1); +}; diff --git a/package-lock.json b/package-lock.json index fe31480..4eb1b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "clsx": "^2.1.1", + "dayjs": "^1.11.21", "expo": "~54.0.34", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", @@ -5932,6 +5933,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 54b450d..bf41afe 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "clsx": "^2.1.1", + "dayjs": "^1.11.21", "expo": "~54.0.34", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", diff --git a/type.d.ts b/type.d.ts index 58c2ad7..ac0a9b2 100644 --- a/type.d.ts +++ b/type.d.ts @@ -1,8 +1,60 @@ -import { ImageSourcePropType } from "react-native"; +import type { ImageSourcePropType } from "react-native"; declare global { + interface AppTab { + name: string; + title: string; + icon: ImageSourcePropType; + } + interface TabIconProps { focused: boolean; icon: ImageSourcePropType; } + + interface Subscription { + id: string; + icon: ImageSourcePropType; + name: string; + plan?: string; + category?: string; + paymentMethod?: string; + status?: string; + startDate?: string; + price: number; + currency?: string; + billing: string; + frequency?: string; + renewalDate?: string; + color?: string; + } + + interface SubscriptionCardProps extends Omit { + expanded: boolean; + onPress: () => void; + onCancelPress?: () => void; + isCancelling?: boolean; + } + + interface UpcomingSubscription { + id: string; + icon: ImageSourcePropType; + name: string; + price: number; + currency?: string; + daysLeft: number; + } + + interface UpcomingSubscriptionCardProps extends Omit< + UpcomingSubscription, + "id" + > {} + + interface ListHeadingProps { + title: string; + onPress?: () => void; + } } + +export { }; +