From 2580d7ed6af3082e9782049c63f53a09601b195c Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:04:29 -0500 Subject: [PATCH 1/5] feat: implement phases 1D-1G (iCal import, notifications, settings, website/API) + deploy checklist - Phase 1D: DB schema (Drizzle/SQLite), iCal parser, Sched extractor, import hook, convention detail screen, import UI, Vitest tests - Phase 1E: Notification service, event reminders hook, action sheet, bell icon, permission banner - Phase 1F: Data export/import services, settings/profile/about screen rewrites, empty states, haptics - Phase 1G: Hono API (Cloudflare Workers), Next.js static site (Cloudflare Pages), Fumadocs scaffold - TODO.md: step-by-step go-live checklist for deploying API + website Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 64 +++ apps/docs/content/docs/api/overview.mdx | 8 + apps/docs/content/docs/dev/setup.mdx | 8 + .../content/docs/guides/getting-started.mdx | 8 + apps/native/.env.example | 2 + apps/native/app.config.ts | 19 +- apps/native/app/(onboarding)/_layout.tsx | 5 + apps/native/app/(onboarding)/complete.tsx | 37 ++ apps/native/app/(onboarding)/features.tsx | 53 +++ apps/native/app/(onboarding)/get-started.tsx | 44 +++ apps/native/app/(onboarding)/welcome.tsx | 38 ++ apps/native/app/(tabs)/_layout.tsx | 48 +++ apps/native/app/(tabs)/index.tsx | 68 ++++ apps/native/app/(tabs)/profile.tsx | 55 +++ apps/native/app/(tabs)/settings.tsx | 148 +++++++ apps/native/app/_layout.tsx | 30 +- apps/native/app/convention/[id].tsx | 373 ++++++++++++++++++ apps/native/app/convention/[id]/_layout.tsx | 5 + apps/native/app/convention/[id]/import.tsx | 292 ++++++++++++++ apps/native/app/index.tsx | 29 +- apps/native/app/settings/_layout.tsx | 11 + apps/native/app/settings/about.tsx | 88 +++++ apps/native/app/settings/language.tsx | 69 ++++ apps/native/drizzle.config.ts | 8 + apps/native/eas.json | 13 +- apps/native/metro.config.js | 4 +- apps/native/nativewind-env.d.ts | 3 +- apps/native/package.json | 10 + apps/native/postcss.config.mjs | 6 +- apps/native/src/components/CategoryPill.tsx | 30 ++ apps/native/src/components/ConventionCard.tsx | 53 +++ apps/native/src/components/EventItem.tsx | 65 +++ .../native/src/components/OnboardingSlide.tsx | 36 ++ apps/native/src/components/SectionHeader.tsx | 19 + apps/native/src/components/ui/Avatar.tsx | 39 ++ apps/native/src/components/ui/Badge.tsx | 35 ++ apps/native/src/components/ui/Button.tsx | 83 ++++ apps/native/src/components/ui/Card.tsx | 41 ++ apps/native/src/components/ui/EmptyState.tsx | 43 ++ apps/native/src/components/ui/Input.tsx | 35 ++ .../src/components/ui/LoadingSpinner.tsx | 15 + apps/native/src/components/ui/SafeView.tsx | 16 + apps/native/src/components/ui/Separator.tsx | 10 + apps/native/src/components/ui/Switch.tsx | 18 + apps/native/src/components/ui/Text.tsx | 26 ++ apps/native/src/components/ui/index.ts | 11 + apps/native/src/db/index.ts | 9 + .../native/src/db/repositories/conventions.ts | 34 ++ apps/native/src/db/repositories/events.ts | 108 +++++ apps/native/src/db/schema.ts | 47 +++ apps/native/src/global.css | 5 +- apps/native/src/hooks/useEventReminder.ts | 100 +++++ apps/native/src/hooks/useImportSchedule.ts | 44 +++ .../src/lib/__tests__/ical-parser.test.ts | 201 ++++++++++ apps/native/src/lib/i18n.ts | 64 +++ apps/native/src/lib/ical-parser.ts | 261 ++++++++++++ apps/native/src/lib/sched-extractor.ts | 64 +++ apps/native/src/locales/de.json | 155 ++++++++ apps/native/src/locales/en.json | 155 ++++++++ apps/native/src/locales/es.json | 155 ++++++++ apps/native/src/locales/fr.json | 155 ++++++++ apps/native/src/locales/nl.json | 155 ++++++++ apps/native/src/locales/pl.json | 155 ++++++++ apps/native/src/locales/pt-BR.json | 155 ++++++++ apps/native/src/locales/sv.json | 155 ++++++++ apps/native/src/services/data-export.ts | 72 ++++ apps/native/src/services/data-import.ts | 176 +++++++++ apps/native/src/services/notifications.ts | 87 ++++ apps/native/tsconfig.json | 4 +- apps/native/vitest.config.ts | 13 + apps/server/package.json | 18 + apps/server/src/index.ts | 87 ++++ apps/server/tsconfig.json | 11 + apps/server/wrangler.toml | 9 + apps/web/.env.example | 1 + apps/web/next.config.ts | 10 +- apps/web/src/app/globals.css | 18 + apps/web/src/app/layout.tsx | 52 +-- apps/web/src/app/page.tsx | 217 ++++++++-- apps/web/src/app/privacy/page.tsx | 78 ++++ apps/web/src/app/terms/page.tsx | 88 +++++ bun.lock | 27 +- 82 files changed, 5170 insertions(+), 96 deletions(-) create mode 100644 TODO.md create mode 100644 apps/docs/content/docs/api/overview.mdx create mode 100644 apps/docs/content/docs/dev/setup.mdx create mode 100644 apps/docs/content/docs/guides/getting-started.mdx create mode 100644 apps/native/app/(onboarding)/_layout.tsx create mode 100644 apps/native/app/(onboarding)/complete.tsx create mode 100644 apps/native/app/(onboarding)/features.tsx create mode 100644 apps/native/app/(onboarding)/get-started.tsx create mode 100644 apps/native/app/(onboarding)/welcome.tsx create mode 100644 apps/native/app/(tabs)/_layout.tsx create mode 100644 apps/native/app/(tabs)/index.tsx create mode 100644 apps/native/app/(tabs)/profile.tsx create mode 100644 apps/native/app/(tabs)/settings.tsx create mode 100644 apps/native/app/convention/[id].tsx create mode 100644 apps/native/app/convention/[id]/_layout.tsx create mode 100644 apps/native/app/convention/[id]/import.tsx create mode 100644 apps/native/app/settings/_layout.tsx create mode 100644 apps/native/app/settings/about.tsx create mode 100644 apps/native/app/settings/language.tsx create mode 100644 apps/native/drizzle.config.ts create mode 100644 apps/native/src/components/CategoryPill.tsx create mode 100644 apps/native/src/components/ConventionCard.tsx create mode 100644 apps/native/src/components/EventItem.tsx create mode 100644 apps/native/src/components/OnboardingSlide.tsx create mode 100644 apps/native/src/components/SectionHeader.tsx create mode 100644 apps/native/src/components/ui/Avatar.tsx create mode 100644 apps/native/src/components/ui/Badge.tsx create mode 100644 apps/native/src/components/ui/Button.tsx create mode 100644 apps/native/src/components/ui/Card.tsx create mode 100644 apps/native/src/components/ui/EmptyState.tsx create mode 100644 apps/native/src/components/ui/Input.tsx create mode 100644 apps/native/src/components/ui/LoadingSpinner.tsx create mode 100644 apps/native/src/components/ui/SafeView.tsx create mode 100644 apps/native/src/components/ui/Separator.tsx create mode 100644 apps/native/src/components/ui/Switch.tsx create mode 100644 apps/native/src/components/ui/Text.tsx create mode 100644 apps/native/src/components/ui/index.ts create mode 100644 apps/native/src/db/index.ts create mode 100644 apps/native/src/db/repositories/conventions.ts create mode 100644 apps/native/src/db/repositories/events.ts create mode 100644 apps/native/src/db/schema.ts create mode 100644 apps/native/src/hooks/useEventReminder.ts create mode 100644 apps/native/src/hooks/useImportSchedule.ts create mode 100644 apps/native/src/lib/__tests__/ical-parser.test.ts create mode 100644 apps/native/src/lib/i18n.ts create mode 100644 apps/native/src/lib/ical-parser.ts create mode 100644 apps/native/src/lib/sched-extractor.ts create mode 100644 apps/native/src/locales/de.json create mode 100644 apps/native/src/locales/en.json create mode 100644 apps/native/src/locales/es.json create mode 100644 apps/native/src/locales/fr.json create mode 100644 apps/native/src/locales/nl.json create mode 100644 apps/native/src/locales/pl.json create mode 100644 apps/native/src/locales/pt-BR.json create mode 100644 apps/native/src/locales/sv.json create mode 100644 apps/native/src/services/data-export.ts create mode 100644 apps/native/src/services/data-import.ts create mode 100644 apps/native/src/services/notifications.ts create mode 100644 apps/native/vitest.config.ts create mode 100644 apps/server/package.json create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/wrangler.toml create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/privacy/page.tsx create mode 100644 apps/web/src/app/terms/page.tsx diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dd8377d --- /dev/null +++ b/TODO.md @@ -0,0 +1,64 @@ +# ConPaws — Go Live Checklist + +## 0. Pre-flight + +- [ ] Run `bun test` (should be 16/16) +- [ ] Commit all Phase 1D–1G changes to main +- [ ] Push to GitHub + +--- + +## 1. API — Cloudflare Workers (`api.conpaws.com`) + +- [ ] `cd apps/server && bun install` +- [ ] `wrangler login` (if not already authed) +- [ ] `wrangler secret put BREVO_API_KEY` (paste key when prompted) +- [ ] Verify `BREVO_LIST_ID` in `wrangler.toml` matches your actual Brevo list ID +- [ ] `wrangler deploy` +- [ ] Smoke test: `curl https://conpaws-api.workers.dev/health` → `{ "status": "ok" }` +- [ ] Add custom domain `api.conpaws.com` in Cloudflare Workers dashboard + +--- + +## 2. Website — Cloudflare Pages (`conpaws.com`) + +- [ ] In Cloudflare Pages: Create project → Connect GitHub repo +- [ ] Framework preset: **Next.js (static)** +- [ ] Build command: `cd apps/web && bun install && bun run build` + - OR: Build command: `bun run build`, Root directory: `apps/web` +- [ ] Output directory: `apps/web/out` +- [ ] Add env var: `NEXT_PUBLIC_API_URL` = `https://api.conpaws.com` +- [ ] Deploy +- [ ] Add custom domain: `conpaws.com` (and `www.conpaws.com`) +- [ ] Verify: `conpaws.com` loads, `/privacy` and `/terms` work + +> **Note:** `apps/web/next.config.ts` uses `output: 'export'` — static files go to `out/`. +> The `"start": "next start"` script in `apps/web/package.json` is **incompatible** with static export and must not be used for production. Cloudflare Pages ignores it, but it should be cleaned up post-launch (see step 5). + +--- + +## 3. DNS + +- [ ] `conpaws.com` → Cloudflare Pages (CNAME to `.pages.dev` URL, or use Cloudflare nameservers for automatic routing) +- [ ] `api.conpaws.com` → Workers custom domain (configured in step 1) +- [ ] `www.conpaws.com` → redirect to `conpaws.com` (set up in Cloudflare Pages → Custom domains) + +--- + +## 4. Smoke Tests + +- [ ] `GET https://api.conpaws.com/health` → `{ "status": "ok" }` +- [ ] `POST https://api.conpaws.com/subscribe` with valid `name` + `email` → `200` +- [ ] `POST https://api.conpaws.com/subscribe` with honeypot field filled → `200` (silent success, no contact created) +- [ ] `https://conpaws.com` → hero, features, and signup form visible +- [ ] `https://conpaws.com/privacy` → loads +- [ ] `https://conpaws.com/terms` → loads +- [ ] Submit signup form on live site → contact appears in Brevo + +--- + +## 5. Post-deploy + +- [ ] Update `DEPLOY_WEB.md` to reflect Cloudflare Pages approach (not Coolify + `next start`) +- [ ] Set up auto-deploy: Cloudflare Pages deploys on every push to `main` (enable in Pages settings) +- [ ] Fix `apps/web/package.json` `start` script — remove or replace `"next start"` with a static serve alternative (e.g. `"serve out"` using the `serve` package), since it will never work with `output: 'export'` diff --git a/apps/docs/content/docs/api/overview.mdx b/apps/docs/content/docs/api/overview.mdx new file mode 100644 index 0000000..c878601 --- /dev/null +++ b/apps/docs/content/docs/api/overview.mdx @@ -0,0 +1,8 @@ +--- +title: API Overview +description: ConPaws API reference +--- + +## API Overview + +Content coming soon. diff --git a/apps/docs/content/docs/dev/setup.mdx b/apps/docs/content/docs/dev/setup.mdx new file mode 100644 index 0000000..38c1a66 --- /dev/null +++ b/apps/docs/content/docs/dev/setup.mdx @@ -0,0 +1,8 @@ +--- +title: Development Setup +description: How to set up the ConPaws development environment +--- + +## Development Setup + +Content coming soon. diff --git a/apps/docs/content/docs/guides/getting-started.mdx b/apps/docs/content/docs/guides/getting-started.mdx new file mode 100644 index 0000000..2e1809c --- /dev/null +++ b/apps/docs/content/docs/guides/getting-started.mdx @@ -0,0 +1,8 @@ +--- +title: Getting Started +description: How to use ConPaws for your first convention +--- + +## Getting Started + +Content coming soon. Check back after the ConPaws launch! diff --git a/apps/native/.env.example b/apps/native/.env.example index 295929d..01877fc 100644 --- a/apps/native/.env.example +++ b/apps/native/.env.example @@ -1,3 +1,5 @@ +APP_VARIANT= + # Supabase EXPO_PUBLIC_SUPABASE_URL= EXPO_PUBLIC_SUPABASE_ANON_KEY= diff --git a/apps/native/app.config.ts b/apps/native/app.config.ts index c6c199b..c10c5ce 100644 --- a/apps/native/app.config.ts +++ b/apps/native/app.config.ts @@ -1,6 +1,8 @@ import type { ConfigContext, ExpoConfig } from "expo/config"; const APP_VARIANT = process.env.APP_VARIANT ?? "production"; +const IS_PRODUCTION = APP_VARIANT === "production"; +const EAS_PROJECT_ID = "0ad7171c-1b3e-48b2-a806-554aeea30048"; const getAppName = (): string => { switch (APP_VARIANT) { @@ -39,15 +41,29 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: getAppName(), slug: "conpaws", + owner: "mrdemonwolf-org", version: "1.0.0", - orientation: "portrait", + orientation: "default", icon: "./assets/images/icon.png", scheme: getScheme(), userInterfaceStyle: "automatic", + runtimeVersion: { policy: "appVersion" }, + updates: { + url: `https://u.expo.dev/${EAS_PROJECT_ID}`, + }, + extra: { + eas: { + projectId: EAS_PROJECT_ID, + }, + }, + ...(IS_PRODUCTION ? {} : { developmentClient: {} }), ios: { supportsTablet: true, bundleIdentifier: getBundleId(), associatedDomains: ["applinks:conpaws.app"], + config: { + usesNonExemptEncryption: false, + }, infoPlist: { NSCameraUsageDescription: "ConPaws needs access to your camera to take photos.", @@ -72,7 +88,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "RECEIVE_BOOT_COMPLETED", "VIBRATE", ], - enableEdgeToEdge: true, }, androidStatusBar: { translucent: true, diff --git a/apps/native/app/(onboarding)/_layout.tsx b/apps/native/app/(onboarding)/_layout.tsx new file mode 100644 index 0000000..dd62276 --- /dev/null +++ b/apps/native/app/(onboarding)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function OnboardingLayout() { + return ; +} diff --git a/apps/native/app/(onboarding)/complete.tsx b/apps/native/app/(onboarding)/complete.tsx new file mode 100644 index 0000000..db09b14 --- /dev/null +++ b/apps/native/app/(onboarding)/complete.tsx @@ -0,0 +1,37 @@ +import { View } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { SafeView, Text, Button } from '@/components/ui'; + +export default function CompleteScreen() { + const { t } = useTranslation(); + + async function handleLetsGo() { + await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); + router.replace('/(tabs)'); + } + + return ( + + + + + + + + {t('onboarding.complete.title')} + + + {t('onboarding.complete.subtitle')} + + + + + + + + ); +} diff --git a/apps/native/app/(onboarding)/features.tsx b/apps/native/app/(onboarding)/features.tsx new file mode 100644 index 0000000..966a699 --- /dev/null +++ b/apps/native/app/(onboarding)/features.tsx @@ -0,0 +1,53 @@ +import { View, ScrollView } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Button } from '@/components/ui'; +import { OnboardingSlide } from '@/components/OnboardingSlide'; + +export default function FeaturesScreen() { + const { t } = useTranslation(); + + return ( + + + + + {t('onboarding.features.title')} + + + {t('onboarding.features.subtitle')} + + + + 📅} + title={t('onboarding.features.calendar.title')} + description={t('onboarding.features.calendar.description')} + /> + 🤝} + title={t('onboarding.features.share.title')} + description={t('onboarding.features.share.description')} + /> + 📴} + title={t('onboarding.features.offline.title')} + description={t('onboarding.features.offline.description')} + /> + + + + + + + ); +} diff --git a/apps/native/app/(onboarding)/get-started.tsx b/apps/native/app/(onboarding)/get-started.tsx new file mode 100644 index 0000000..9833f3d --- /dev/null +++ b/apps/native/app/(onboarding)/get-started.tsx @@ -0,0 +1,44 @@ +import { View } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Button, Separator } from '@/components/ui'; + +export default function GetStartedScreen() { + const { t } = useTranslation(); + + return ( + + + + + {t('onboarding.getStarted.title')} + + + {t('onboarding.getStarted.subtitle')} + + + + + + + + + + + + {t('onboarding.getStarted.legal')} + + + + ); +} diff --git a/apps/native/app/(onboarding)/welcome.tsx b/apps/native/app/(onboarding)/welcome.tsx new file mode 100644 index 0000000..950c614 --- /dev/null +++ b/apps/native/app/(onboarding)/welcome.tsx @@ -0,0 +1,38 @@ +import { View } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Button } from '@/components/ui'; + +export default function WelcomeScreen() { + const { t } = useTranslation(); + + return ( + + + + + CP + + + ConPaws + + + {t('onboarding.welcome.tagline')} + + + + {t('onboarding.welcome.subtitle')} + + + + + + + ); +} diff --git a/apps/native/app/(tabs)/_layout.tsx b/apps/native/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..58bdfe7 --- /dev/null +++ b/apps/native/app/(tabs)/_layout.tsx @@ -0,0 +1,48 @@ +import { Tabs } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Ionicons } from '@expo/vector-icons'; + +export default function TabsLayout() { + const { t } = useTranslation(); + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/apps/native/app/(tabs)/index.tsx b/apps/native/app/(tabs)/index.tsx new file mode 100644 index 0000000..04e7ac1 --- /dev/null +++ b/apps/native/app/(tabs)/index.tsx @@ -0,0 +1,68 @@ +import { View, FlatList, Pressable } from 'react-native'; +import { router } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import * as Haptics from 'expo-haptics'; +import { SafeView, Text, EmptyState, LoadingSpinner } from '@/components/ui'; +import { ConventionCard } from '@/components/ConventionCard'; +import * as conventionsRepo from '@/db/repositories/conventions'; + +export default function HomeScreen() { + const { t } = useTranslation(); + + const { data: conventions = [], isLoading } = useQuery({ + queryKey: ['conventions'], + queryFn: conventionsRepo.getAll, + }); + + function handleAddConvention() { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + router.push('/convention/new'); + } + + return ( + + + {t('home.title')} + + + + + + + {isLoading ? ( + + + + ) : conventions.length === 0 ? ( + 🐾} + title={t('home.empty.title')} + subtitle={t('home.empty.subtitle')} + ctaLabel={t('home.empty.cta')} + onCta={handleAddConvention} + /> + ) : ( + item.id} + contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8, gap: 12 }} + renderItem={({ item }) => ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + router.push(`/convention/${item.id}`); + }} + /> + )} + /> + )} + + ); +} diff --git a/apps/native/app/(tabs)/profile.tsx b/apps/native/app/(tabs)/profile.tsx new file mode 100644 index 0000000..4bb43ae --- /dev/null +++ b/apps/native/app/(tabs)/profile.tsx @@ -0,0 +1,55 @@ +import { View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Button, Badge } from '@/components/ui'; +import { UserCircle } from 'lucide-react-native'; + +export default function ProfileScreen() { + const { t } = useTranslation(); + + return ( + + + {t('profile.title')} + + + + + Create an account + + Set up your profile and sync your schedule across devices + + + + + {/* Apple Sign In */} + + + + + Coming Soon + + + + + {/* Google Sign In */} + + + + + Coming Soon + + + + + + + Account sync is coming in Phase 2. Your data is safely stored locally for now. + + + + ); +} diff --git a/apps/native/app/(tabs)/settings.tsx b/apps/native/app/(tabs)/settings.tsx new file mode 100644 index 0000000..75424a6 --- /dev/null +++ b/apps/native/app/(tabs)/settings.tsx @@ -0,0 +1,148 @@ +import { View, ScrollView, Pressable, Alert, Linking } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; +import Constants from 'expo-constants'; +import { SafeView, Text, Separator } from '@/components/ui'; +import { SectionHeader } from '@/components/SectionHeader'; +import i18n, { type SupportedLanguage } from '@/lib/i18n'; +import { useExportData } from '@/services/data-export'; +import { useImportData } from '@/services/data-import'; + +interface SettingsRowProps { + label: string; + subtitle?: string; + onPress?: () => void; + destructive?: boolean; + badge?: string; + disabled?: boolean; +} + +function SettingsRow({ label, subtitle, onPress, destructive = false, badge, disabled = false }: SettingsRowProps) { + return ( + + + + {label} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {badge ? ( + + {badge} + + ) : null} + + + ); +} + +export default function SettingsScreen() { + const { t } = useTranslation(); + const { exportData, isLoading: isExporting } = useExportData(); + const { importData, isLoading: isImporting } = useImportData(); + + const version = Constants.expoConfig?.version ?? '0.0.0'; + + async function handleResetOnboarding() { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Warning); + Alert.alert( + 'Reset Onboarding', + 'This will show the onboarding screens again next time you open the app.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reset', + style: 'destructive', + onPress: async () => { + await AsyncStorage.removeItem('hasCompletedOnboarding'); + router.replace('/(onboarding)/welcome'); + }, + }, + ], + ); + } + + function handleSignIn() { + Alert.alert('Coming Soon', 'Account sync is coming in a future update.'); + } + + return ( + + + {t('settings.title')} + + + {/* Account */} + + + + {/* App */} + + router.push('/settings/language')} + /> + + + + router.push('/settings/about')} + /> + + {/* Data */} + + exportData()} + disabled={isExporting} + /> + + importData()} + disabled={isImporting} + /> + + {/* Legal */} + + Linking.openURL('https://conpaws.com/privacy')} + /> + + Linking.openURL('https://conpaws.com/terms')} + /> + + + {t('common.version', { version })} + + + + ); +} diff --git a/apps/native/app/_layout.tsx b/apps/native/app/_layout.tsx index 3775667..ae9ff7f 100644 --- a/apps/native/app/_layout.tsx +++ b/apps/native/app/_layout.tsx @@ -1,7 +1,35 @@ import "../src/global.css"; +import { useEffect, useState } from "react"; import { Stack } from "expo-router"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as SplashScreen from "expo-splash-screen"; +import { initI18n } from "@/lib/i18n"; +import { setupNotificationHandler } from "@/services/notifications"; + +SplashScreen.preventAutoHideAsync(); +setupNotificationHandler(); + +const queryClient = new QueryClient(); export default function RootLayout() { - return ; + const [ready, setReady] = useState(false); + + useEffect(() => { + initI18n().finally(async () => { + setReady(true); + await SplashScreen.hideAsync(); + }); + }, []); + + if (!ready) return null; + + return ( + + + + + + ); } diff --git a/apps/native/app/convention/[id].tsx b/apps/native/app/convention/[id].tsx new file mode 100644 index 0000000..03bc4a3 --- /dev/null +++ b/apps/native/app/convention/[id].tsx @@ -0,0 +1,373 @@ +import { useState, useCallback } from 'react'; +import { + View, + ScrollView, + Pressable, + Modal, + FlatList, + Alert, + Linking, +} from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import * as Haptics from 'expo-haptics'; +import { ChevronLeft, MoreHorizontal, Upload, Plus } from 'lucide-react-native'; +import { SafeView, Text, EmptyState, LoadingSpinner } from '@/components/ui'; +import { EventItem } from '@/components/EventItem'; +import { CategoryPill } from '@/components/CategoryPill'; +import { SectionHeader } from '@/components/SectionHeader'; +import * as conventionsRepo from '@/db/repositories/conventions'; +import * as eventsRepo from '@/db/repositories/events'; +import type { ConventionEvent } from '@/db/schema'; +import { format, isSameDay } from 'date-fns'; + +interface DayGroup { + date: Date; + label: string; + events: ConventionEvent[]; +} + +function groupEventsByDay(events: ConventionEvent[]): DayGroup[] { + const groups: DayGroup[] = []; + + for (const event of events) { + const startDate = new Date(event.startTime); + const existing = groups.find((g) => isSameDay(g.date, startDate)); + if (existing) { + existing.events.push(event); + } else { + groups.push({ + date: startDate, + label: format(startDate, 'EEEE, MMMM d'), + events: [event], + }); + } + } + + return groups.sort((a, b) => a.date.getTime() - b.date.getTime()); +} + +function formatTime(isoString: string | null): string { + if (!isoString) return ''; + return format(new Date(isoString), 'h:mm a'); +} + +interface ActionSheetProps { + event: ConventionEvent | null; + visible: boolean; + onClose: () => void; + onToggleSchedule: (event: ConventionEvent) => void; + onSetReminder: (event: ConventionEvent) => void; +} + +function EventActionSheet({ event, visible, onClose, onToggleSchedule, onSetReminder }: ActionSheetProps) { + if (!event) return null; + + return ( + + + + + + {event.title} + + + {/* Toggle schedule */} + { onToggleSchedule(event); onClose(); }} + className="px-4 py-3.5 active:opacity-70" + > + + {event.isInSchedule ? 'Remove from Schedule' : 'Add to Schedule'} + + + + {/* Set reminder */} + { onSetReminder(event); onClose(); }} + className="px-4 py-3.5 active:opacity-70" + > + + {event.reminderMinutes !== null ? 'Change Reminder' : 'Set Reminder'} + + + + {/* View on Sched */} + {event.sourceUrl && ( + { Linking.openURL(event.sourceUrl!); onClose(); }} + className="px-4 py-3.5 active:opacity-70" + > + View on Sched + + )} + + {/* Cancel */} + + Cancel + + + + + ); +} + +interface ReminderPickerProps { + event: ConventionEvent | null; + visible: boolean; + onClose: () => void; + onSelect: (event: ConventionEvent, minutes: number | null) => void; +} + +const REMINDER_OPTIONS = [ + { label: 'No reminder', value: null }, + { label: '5 min before', value: 5 }, + { label: '15 min before', value: 15 }, + { label: '30 min before', value: 30 }, + { label: '1 hour before', value: 60 }, +]; + +function ReminderPicker({ event, visible, onClose, onSelect }: ReminderPickerProps) { + if (!event) return null; + + return ( + + + + + Set Reminder + {REMINDER_OPTIONS.map((opt) => ( + { onSelect(event, opt.value); onClose(); }} + className="px-4 py-3.5 active:opacity-70 flex-row items-center justify-between" + > + {opt.label} + {event.reminderMinutes === opt.value && ( + + )} + + ))} + + + + ); +} + +export default function ConventionDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const [selectedCategory, setSelectedCategory] = useState(null); + const [actionSheetEvent, setActionSheetEvent] = useState(null); + const [reminderEvent, setReminderEvent] = useState(null); + + const { data: convention, isLoading: conventionLoading } = useQuery({ + queryKey: ['convention', id], + queryFn: () => conventionsRepo.getById(id!), + enabled: !!id, + }); + + const { data: events = [], isLoading: eventsLoading } = useQuery({ + queryKey: ['events', id], + queryFn: () => eventsRepo.getByConventionId(id!), + enabled: !!id, + }); + + const toggleScheduleMutation = useMutation({ + mutationFn: async (event: ConventionEvent) => { + await eventsRepo.update(event.id, { isInSchedule: !event.isInSchedule }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events', id] }); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + }, + }); + + const setReminderMutation = useMutation({ + mutationFn: async ({ event, minutes }: { event: ConventionEvent; minutes: number | null }) => { + await eventsRepo.update(event.id, { reminderMinutes: minutes }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events', id] }); + }, + }); + + const categories = Array.from(new Set(events.map((e) => e.category).filter(Boolean) as string[])); + + const filteredEvents = selectedCategory + ? events.filter((e) => e.category === selectedCategory) + : events; + + const dayGroups = groupEventsByDay(filteredEvents); + + const isLoading = conventionLoading || eventsLoading; + + if (isLoading) { + return ( + + + + + + ); + } + + if (!convention) { + return ( + + + Convention not found. + router.back()} className="mt-4 active:opacity-70"> + Go back + + + + ); + } + + return ( + + {/* Header */} + + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); router.back(); }} + className="active:opacity-70" + > + + + {convention.name} + router.push(`/convention/${id}/import`)} + className="active:opacity-70" + > + + + + + {/* Category filter pills */} + {categories.length > 0 && ( + + setSelectedCategory(null)} + className={`px-3 py-1.5 rounded-full ${selectedCategory === null ? 'bg-primary' : 'bg-card'}`} + > + + All + + + {categories.map((cat) => ( + setSelectedCategory(selectedCategory === cat ? null : cat)} + className={`px-3 py-1.5 rounded-full ${selectedCategory === cat ? 'bg-primary' : 'bg-card'}`} + > + + {cat} + + + ))} + + )} + + {/* Events list or empty state */} + {events.length === 0 ? ( + + 📅} + title={t('convention.noEvents')} + subtitle={t('convention.noEventsSubtitle')} + ctaLabel={t('convention.importSchedule')} + onCta={() => router.push(`/convention/${id}/import`)} + /> + {}} + className="active:opacity-70" + > + + Add event manually + + + ) : ( + item.label} + renderItem={({ item: group }) => ( + + + {group.events.map((event) => ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + onLongPress={() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setActionSheetEvent(event); + }} + /> + ))} + + )} + /> + )} + + {/* Action Sheet */} + setActionSheetEvent(null)} + onToggleSchedule={(event) => toggleScheduleMutation.mutate(event)} + onSetReminder={(event) => setReminderEvent(event)} + /> + + {/* Reminder Picker */} + setReminderEvent(null)} + onSelect={(event, minutes) => setReminderMutation.mutate({ event, minutes })} + /> + + ); +} diff --git a/apps/native/app/convention/[id]/_layout.tsx b/apps/native/app/convention/[id]/_layout.tsx new file mode 100644 index 0000000..d0bee5a --- /dev/null +++ b/apps/native/app/convention/[id]/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function ConventionSubLayout() { + return ; +} diff --git a/apps/native/app/convention/[id]/import.tsx b/apps/native/app/convention/[id]/import.tsx new file mode 100644 index 0000000..1e64d7d --- /dev/null +++ b/apps/native/app/convention/[id]/import.tsx @@ -0,0 +1,292 @@ +import { useState, useCallback } from 'react'; +import { + View, + ScrollView, + TextInput, + Pressable, + ActivityIndicator, + Alert, +} from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import * as DocumentPicker from 'expo-document-picker'; +import * as FileSystem from 'expo-file-system'; +import * as Haptics from 'expo-haptics'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Button } from '@/components/ui'; +import { parseIcs, type ParsedEvent, type CategoryMeta } from '@/lib/ical-parser'; +import { fetchSchedIcs, InvalidSchedUrlError, NetworkError, InvalidResponseError } from '@/lib/sched-extractor'; +import { useImportSchedule } from '@/hooks/useImportSchedule'; +import { FileX, WifiOff, CalendarX, ChevronLeft } from 'lucide-react-native'; + +type Tab = 'file' | 'url'; + +interface ErrorState { + type: 'file-type' | 'network' | 'no-events' | 'parse'; + message: string; +} + +interface PreviewState { + events: ParsedEvent[]; + categories: CategoryMeta[]; + selectedCategories: Set; +} + +export default function ImportScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { t } = useTranslation(); + const importMutation = useImportSchedule(); + + const [activeTab, setActiveTab] = useState('file'); + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + + function clearState() { + setError(null); + setPreview(null); + } + + function processIcs(icsContent: string) { + const result = parseIcs(icsContent); + + if (result.events.length === 0) { + setError({ type: 'no-events', message: 'No events found in this calendar file.' }); + return; + } + + const allCategories = new Set(result.categories.map((c) => c.name)); + setPreview({ + events: result.events, + categories: result.categories, + selectedCategories: allCategories, + }); + } + + async function handleFilePick() { + clearState(); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ['text/calendar', 'application/octet-stream', '*/*'], + copyToCacheDirectory: true, + }); + + if (result.canceled) return; + + const file = result.assets[0]; + if (!file.name.endsWith('.ics')) { + setError({ type: 'file-type', message: 'Please select a .ics calendar file.' }); + return; + } + + setLoading(true); + const content = await FileSystem.readAsStringAsync(file.uri); + processIcs(content); + } catch { + setError({ type: 'parse', message: 'Failed to read the file.' }); + } finally { + setLoading(false); + } + } + + async function handleUrlFetch() { + clearState(); + if (!url.trim()) return; + setLoading(true); + + try { + const content = await fetchSchedIcs(url.trim()); + processIcs(content); + } catch (err) { + if (err instanceof InvalidSchedUrlError) { + setError({ type: 'file-type', message: 'Enter a valid Sched URL (e.g. https://yourcon.sched.com).' }); + } else if (err instanceof NetworkError) { + setError({ type: 'network', message: 'Network error. Check your connection and try again.' }); + } else if (err instanceof InvalidResponseError) { + setError({ type: 'no-events', message: 'URL did not return a valid calendar file.' }); + } else { + setError({ type: 'parse', message: 'Something went wrong. Please try again.' }); + } + } finally { + setLoading(false); + } + } + + function toggleCategory(name: string) { + if (!preview) return; + const next = new Set(preview.selectedCategories); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + setPreview({ ...preview, selectedCategories: next }); + } + + async function handleImport() { + if (!preview || !id) return; + + const eventsToImport = preview.selectedCategories.size === preview.categories.length + ? preview.events + : preview.events.filter((e) => + e.category === null || preview.selectedCategories.has(e.category), + ); + + try { + const result = await importMutation.mutateAsync({ + parsedEvents: eventsToImport, + conventionId: id, + }); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + + Alert.alert( + 'Import Complete', + `${result.added} added, ${result.updated} updated`, + [{ text: 'Done', onPress: () => router.back() }], + ); + } catch { + Alert.alert('Import Failed', 'Something went wrong. Please try again.'); + } + } + + const selectedCount = preview + ? preview.events.filter( + (e) => e.category === null || preview.selectedCategories.has(e.category), + ).length + : 0; + + return ( + + {/* Header */} + + router.back()} className="active:opacity-70"> + + + Import Schedule + + + {/* Tabs */} + + {(['file', 'url'] as Tab[]).map((tab) => ( + { setActiveTab(tab); clearState(); }} + className={`flex-1 py-2 rounded-lg items-center ${ + activeTab === tab ? 'bg-primary' : 'bg-card' + }`} + > + + {tab === 'file' ? 'From File' : 'From URL'} + + + ))} + + + + {/* Tab content */} + {activeTab === 'file' ? ( + + ) : ( + + + + + )} + + {/* Loading */} + {loading && ( + + + Loading... + + )} + + {/* Error state */} + {error && !loading && ( + + {error.type === 'network' ? ( + + ) : error.type === 'no-events' ? ( + + ) : ( + + )} + + {error.message} + + + + )} + + {/* Preview */} + {preview && !loading && !error && ( + + {preview.events.length} events found + + {/* Category checkboxes */} + {preview.categories.map((cat) => { + const checked = preview.selectedCategories.has(cat.name); + return ( + toggleCategory(cat.name)} + className="flex-row items-center gap-3 py-2" + > + + {checked && } + + + {cat.name} + {cat.count} + + ); + })} + + + + + + )} + + + ); +} diff --git a/apps/native/app/index.tsx b/apps/native/app/index.tsx index 26ee837..c4a67be 100644 --- a/apps/native/app/index.tsx +++ b/apps/native/app/index.tsx @@ -1,15 +1,18 @@ -import { View, Text } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { Redirect } from "expo-router"; +import AsyncStorage from "@react-native-async-storage/async-storage"; -export default function HomeScreen() { - return ( - - - ConPaws - - Navigate, Connect, Enjoy - - - - ); +export default function Index() { + const [hasOnboarded, setHasOnboarded] = useState(null); + + useEffect(() => { + AsyncStorage.getItem("hasCompletedOnboarding").then((value) => { + setHasOnboarded(!!value); + }); + }, []); + + if (hasOnboarded === null) return ; + if (hasOnboarded) return ; + return ; } diff --git a/apps/native/app/settings/_layout.tsx b/apps/native/app/settings/_layout.tsx new file mode 100644 index 0000000..18e32e2 --- /dev/null +++ b/apps/native/app/settings/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; + +export default function SettingsLayout() { + return ( + + ); +} diff --git a/apps/native/app/settings/about.tsx b/apps/native/app/settings/about.tsx new file mode 100644 index 0000000..f421c36 --- /dev/null +++ b/apps/native/app/settings/about.tsx @@ -0,0 +1,88 @@ +import { View, ScrollView, Pressable, Linking } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import Constants from 'expo-constants'; +import { SafeView, Text, Separator } from '@/components/ui'; +import { Github, Globe, Mail, MessageCircle } from 'lucide-react-native'; + +interface LinkRowProps { + icon: React.ReactNode; + label: string; + onPress: () => void; +} + +function LinkRow({ icon, label, onPress }: LinkRowProps) { + return ( + + {icon} + {label} + + ); +} + +export default function AboutScreen() { + const { t } = useTranslation(); + const version = Constants.expoConfig?.version ?? '0.0.0'; + + return ( + + + {/* Logo + Name */} + + + CP + + + ConPaws + + Your furry convention companion + + + + {t('common.version', { version })} + + + + + + {/* Links */} + + } + label="GitHub (Open Source)" + onPress={() => Linking.openURL('https://github.com/mrdemonwolf/conpaws')} + /> + + } + label="mrdemonwolf.com" + onPress={() => Linking.openURL('https://mrdemonwolf.com')} + /> + + } + label="hello@conpaws.com" + onPress={() => Linking.openURL('mailto:hello@conpaws.com')} + /> + + } + label="Join our Discord" + onPress={() => Linking.openURL('https://discord.gg/conpaws')} + /> + + + + + Made with ❤️ for the furry community + + + © 2026 ConPaws. All rights reserved. + + + + + ); +} diff --git a/apps/native/app/settings/language.tsx b/apps/native/app/settings/language.tsx new file mode 100644 index 0000000..ad37538 --- /dev/null +++ b/apps/native/app/settings/language.tsx @@ -0,0 +1,69 @@ +import { View, ScrollView, Pressable } from 'react-native'; +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { SafeView, Text, Separator } from '@/components/ui'; +import { + SUPPORTED_LANGUAGES, + LANGUAGE_META, + changeLanguage, + type SupportedLanguage, +} from '@/lib/i18n'; +import i18n from '@/lib/i18n'; + +export default function LanguageScreen() { + const { t } = useTranslation(); + const currentLanguage = i18n.language as SupportedLanguage; + + async function handleSelect(code: SupportedLanguage) { + await changeLanguage(code); + router.back(); + } + + return ( + + + router.back()} className="mr-3 active:opacity-70"> + + ‹ {t('common.back')} + + + {t('settings.languages.title')} + + + + {SUPPORTED_LANGUAGES.map((code, index) => { + const { nativeName, flag } = LANGUAGE_META[code]; + const isSelected = currentLanguage === code; + const isLast = index === SUPPORTED_LANGUAGES.length - 1; + + return ( + + handleSelect(code)} + className="px-4 py-3.5 flex-row items-center justify-between active:opacity-70 bg-card" + > + + {flag} + + + {nativeName} + + + {t(`settings.languages.${code}`, { defaultValue: nativeName })} + + + + {isSelected && ( + + ✓ + + )} + + {!isLast && } + + ); + })} + + + ); +} diff --git a/apps/native/drizzle.config.ts b/apps/native/drizzle.config.ts new file mode 100644 index 0000000..16a96e6 --- /dev/null +++ b/apps/native/drizzle.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'sqlite', + driver: 'expo', +} satisfies Config; diff --git a/apps/native/eas.json b/apps/native/eas.json index b50b9c4..8891c58 100644 --- a/apps/native/eas.json +++ b/apps/native/eas.json @@ -7,31 +7,38 @@ "development": { "developmentClient": true, "distribution": "internal", + "bun": "latest", "ios": { "simulator": true }, "env": { "APP_VARIANT": "development" }, - "channel": "development" + "channel": "development", + "local": true }, "development:device": { "developmentClient": true, "distribution": "internal", + "bun": "latest", "env": { "APP_VARIANT": "development" }, - "channel": "development" + "channel": "development", + "local": true }, "preview": { "distribution": "internal", + "bun": "latest", "env": { "APP_VARIANT": "preview" }, - "channel": "preview" + "channel": "preview", + "local": true }, "production": { "autoIncrement": true, + "bun": "latest", "env": { "APP_VARIANT": "production" }, diff --git a/apps/native/metro.config.js b/apps/native/metro.config.js index 16bc22c..30dfc4d 100644 --- a/apps/native/metro.config.js +++ b/apps/native/metro.config.js @@ -3,6 +3,4 @@ const { withNativeWind } = require("nativewind/metro"); const config = getDefaultConfig(__dirname); -module.exports = withNativeWind(config, { - input: "./src/global.css", -}); +module.exports = withNativeWind(config); diff --git a/apps/native/nativewind-env.d.ts b/apps/native/nativewind-env.d.ts index a13e313..c884d1c 100644 --- a/apps/native/nativewind-env.d.ts +++ b/apps/native/nativewind-env.d.ts @@ -1 +1,2 @@ -/// +/// +/// diff --git a/apps/native/package.json b/apps/native/package.json index c238bbe..979211f 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -25,7 +25,9 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.39.3", "expo": "55.0.4", + "expo-constants": "^55.0.8", "expo-document-picker": "~55.0.8", + "expo-file-system": "^55.0.11", "expo-haptics": "~55.0.8", "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", @@ -40,7 +42,9 @@ "react": "19.0.0", "react-i18next": "^16.5.4", "react-native": "0.83.0", + "react-native-css": "^3.0.5", "react-native-reanimated": "4.2.1", + "react-native-worklets": "*", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "react-native-web": "^0.21.2", @@ -48,9 +52,15 @@ "tailwindcss": "^4.1.18" }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", "@types/react": "^19.2.14", "drizzle-kit": "^0.30.6", + "lightningcss": "1.30.1", + "postcss": "^8.5.8", "typescript": "~5.9.3", "vitest": "^3.2.4" + }, + "overrides": { + "lightningcss": "1.30.1" } } diff --git a/apps/native/postcss.config.mjs b/apps/native/postcss.config.mjs index ee5f90b..a9b5c43 100644 --- a/apps/native/postcss.config.mjs +++ b/apps/native/postcss.config.mjs @@ -1,5 +1 @@ -module.exports = { - plugins: { - tailwindcss: {}, - }, -}; +export default { plugins: { "@tailwindcss/postcss": {} } }; diff --git a/apps/native/src/components/CategoryPill.tsx b/apps/native/src/components/CategoryPill.tsx new file mode 100644 index 0000000..bfa693a --- /dev/null +++ b/apps/native/src/components/CategoryPill.tsx @@ -0,0 +1,30 @@ +import { Pressable } from 'react-native'; +import { Text } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +interface CategoryPillProps { + label: string; + selected?: boolean; + onPress?: () => void; + className?: string; +} + +export function CategoryPill({ label, selected = false, onPress, className }: CategoryPillProps) { + return ( + + + {label} + + + ); +} diff --git a/apps/native/src/components/ConventionCard.tsx b/apps/native/src/components/ConventionCard.tsx new file mode 100644 index 0000000..19909f3 --- /dev/null +++ b/apps/native/src/components/ConventionCard.tsx @@ -0,0 +1,53 @@ +import { Pressable, View } from 'react-native'; +import { Text, Badge, Card, CardContent } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +interface ConventionCardProps { + id: string; + name: string; + startDate: string; + endDate: string; + status: 'upcoming' | 'active' | 'ended'; + eventCount: number; + onPress?: () => void; + className?: string; +} + +const statusLabels: Record = { + upcoming: 'Upcoming', + active: 'Active', + ended: 'Ended', +}; + +export function ConventionCard({ + name, + startDate, + endDate, + status, + eventCount, + onPress, + className, +}: ConventionCardProps) { + return ( + + + + + + {name} + + {startDate} – {endDate} + + + + + + + {eventCount} event{eventCount !== 1 ? 's' : ''} + + + + + + ); +} diff --git a/apps/native/src/components/EventItem.tsx b/apps/native/src/components/EventItem.tsx new file mode 100644 index 0000000..dbe5292 --- /dev/null +++ b/apps/native/src/components/EventItem.tsx @@ -0,0 +1,65 @@ +import { View, Pressable } from 'react-native'; +import { Bell, AlertTriangle, ShieldAlert } from 'lucide-react-native'; +import { Text } from '@/components/ui'; +import { CategoryPill } from './CategoryPill'; +import { cn } from '@/lib/utils'; + +interface EventItemProps { + title: string; + startTime: string; + endTime?: string; + room?: string; + category?: string; + isInSchedule?: boolean; + hasReminder?: boolean; + isAgeRestricted?: boolean; + contentWarning?: boolean; + onPress?: () => void; + onLongPress?: () => void; + className?: string; +} + +export function EventItem({ + title, + startTime, + endTime, + room, + category, + isInSchedule = false, + hasReminder = false, + isAgeRestricted = false, + contentWarning = false, + onPress, + onLongPress, + className, +}: EventItemProps) { + return ( + + + + + + {title} + + + {isAgeRestricted && } + {contentWarning && } + {hasReminder && } + {isInSchedule && } + + + + {startTime} + {endTime ? ` – ${endTime}` : ''} + {room ? ` · ${room}` : ''} + + {category ? : null} + + + ); +} diff --git a/apps/native/src/components/OnboardingSlide.tsx b/apps/native/src/components/OnboardingSlide.tsx new file mode 100644 index 0000000..fe4ca64 --- /dev/null +++ b/apps/native/src/components/OnboardingSlide.tsx @@ -0,0 +1,36 @@ +import { View } from 'react-native'; +import { Text } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +interface OnboardingSlideProps { + icon: React.ReactNode; + title: string; + description: string; + action?: React.ReactNode; + className?: string; +} + +export function OnboardingSlide({ + icon, + title, + description, + action, + className, +}: OnboardingSlideProps) { + return ( + + + {icon} + + + + {title} + + + {description} + + + {action ? {action} : null} + + ); +} diff --git a/apps/native/src/components/SectionHeader.tsx b/apps/native/src/components/SectionHeader.tsx new file mode 100644 index 0000000..036fe88 --- /dev/null +++ b/apps/native/src/components/SectionHeader.tsx @@ -0,0 +1,19 @@ +import { View } from 'react-native'; +import { Text, Separator } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +interface SectionHeaderProps { + title: string; + className?: string; +} + +export function SectionHeader({ title, className }: SectionHeaderProps) { + return ( + + + {title} + + + + ); +} diff --git a/apps/native/src/components/ui/Avatar.tsx b/apps/native/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..c406a41 --- /dev/null +++ b/apps/native/src/components/ui/Avatar.tsx @@ -0,0 +1,39 @@ +import { View, Image } from 'react-native'; +import { Text } from './Text'; +import { cn } from '@/lib/utils'; + +type AvatarSize = 'sm' | 'md' | 'lg'; + +interface AvatarProps { + uri?: string; + initials?: string; + size?: AvatarSize; + className?: string; +} + +const sizeStyles: Record = { + sm: { container: 'w-8 h-8 rounded-full', text: 'text-xs font-semibold', px: 32 }, + md: { container: 'w-12 h-12 rounded-full', text: 'text-sm font-semibold', px: 48 }, + lg: { container: 'w-20 h-20 rounded-full', text: 'text-xl font-semibold', px: 80 }, +}; + +export function Avatar({ uri, initials, size = 'md', className }: AvatarProps) { + const styles = sizeStyles[size]; + return ( + + {uri ? ( + + ) : ( + + {initials ?? '?'} + + )} + + ); +} diff --git a/apps/native/src/components/ui/Badge.tsx b/apps/native/src/components/ui/Badge.tsx new file mode 100644 index 0000000..c397be9 --- /dev/null +++ b/apps/native/src/components/ui/Badge.tsx @@ -0,0 +1,35 @@ +import { View } from 'react-native'; +import { Text } from './Text'; +import { cn } from '@/lib/utils'; + +type BadgeVariant = 'upcoming' | 'active' | 'ended'; + +interface BadgeProps { + variant: BadgeVariant; + label: string; + className?: string; +} + +const variantStyles: Record = { + upcoming: { + container: 'bg-blue-100 dark:bg-blue-900/30', + text: 'text-blue-700 dark:text-blue-300', + }, + active: { + container: 'bg-green-100 dark:bg-green-900/30', + text: 'text-green-700 dark:text-green-300', + }, + ended: { + container: 'bg-muted', + text: 'text-muted-foreground', + }, +}; + +export function Badge({ variant, label, className }: BadgeProps) { + const styles = variantStyles[variant]; + return ( + + {label} + + ); +} diff --git a/apps/native/src/components/ui/Button.tsx b/apps/native/src/components/ui/Button.tsx new file mode 100644 index 0000000..d8ddc4d --- /dev/null +++ b/apps/native/src/components/ui/Button.tsx @@ -0,0 +1,83 @@ +import { Pressable, ActivityIndicator } from 'react-native'; +import { Text } from './Text'; +import { cn } from '@/lib/utils'; + +type ButtonVariant = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps { + onPress?: () => void; + children: React.ReactNode; + variant?: ButtonVariant; + size?: ButtonSize; + disabled?: boolean; + loading?: boolean; + className?: string; +} + +const variantStyles: Record = { + default: 'bg-primary', + secondary: 'bg-secondary', + outline: 'border border-border bg-transparent', + ghost: 'bg-transparent', + destructive: 'bg-destructive', +}; + +const textVariantStyles: Record = { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + outline: 'text-foreground', + ghost: 'text-foreground', + destructive: 'text-destructive-foreground', +}; + +const sizeStyles: Record = { + sm: 'px-3 py-1.5 rounded-lg', + md: 'px-4 py-2.5 rounded-xl', + lg: 'px-6 py-3.5 rounded-xl', +}; + +const textSizeStyles: Record = { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', +}; + +export function Button({ + onPress, + children, + variant = 'default', + size = 'md', + disabled = false, + loading = false, + className, +}: ButtonProps) { + return ( + + {loading ? ( + + ) : typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + ); +} diff --git a/apps/native/src/components/ui/Card.tsx b/apps/native/src/components/ui/Card.tsx new file mode 100644 index 0000000..39dc115 --- /dev/null +++ b/apps/native/src/components/ui/Card.tsx @@ -0,0 +1,41 @@ +import { View, type ViewProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface CardProps extends ViewProps { + className?: string; +} + +export function Card({ className, children, ...props }: CardProps) { + return ( + + {children} + + ); +} + +export function CardHeader({ className, children, ...props }: CardProps) { + return ( + + {children} + + ); +} + +export function CardContent({ className, children, ...props }: CardProps) { + return ( + + {children} + + ); +} + +export function CardFooter({ className, children, ...props }: CardProps) { + return ( + + {children} + + ); +} diff --git a/apps/native/src/components/ui/EmptyState.tsx b/apps/native/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..e503a53 --- /dev/null +++ b/apps/native/src/components/ui/EmptyState.tsx @@ -0,0 +1,43 @@ +import { View } from 'react-native'; +import { Text } from './Text'; +import { Button } from './Button'; +import { cn } from '@/lib/utils'; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + subtitle?: string; + ctaLabel?: string; + onCta?: () => void; + className?: string; +} + +export function EmptyState({ + icon, + title, + subtitle, + ctaLabel, + onCta, + className, +}: EmptyStateProps) { + return ( + + {icon ? {icon} : null} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {ctaLabel && onCta ? ( + + ) : null} + + ); +} diff --git a/apps/native/src/components/ui/Input.tsx b/apps/native/src/components/ui/Input.tsx new file mode 100644 index 0000000..597f377 --- /dev/null +++ b/apps/native/src/components/ui/Input.tsx @@ -0,0 +1,35 @@ +import { View, TextInput, type TextInputProps } from 'react-native'; +import { Text } from './Text'; +import { cn } from '@/lib/utils'; + +interface InputProps extends TextInputProps { + label?: string; + error?: string; + className?: string; +} + +export function Input({ label, error, className, ...props }: InputProps) { + return ( + + {label ? ( + + {label} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/apps/native/src/components/ui/LoadingSpinner.tsx b/apps/native/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..4e22529 --- /dev/null +++ b/apps/native/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,15 @@ +import { ActivityIndicator, View } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface LoadingSpinnerProps { + size?: 'small' | 'large'; + className?: string; +} + +export function LoadingSpinner({ size = 'large', className }: LoadingSpinnerProps) { + return ( + + + + ); +} diff --git a/apps/native/src/components/ui/SafeView.tsx b/apps/native/src/components/ui/SafeView.tsx new file mode 100644 index 0000000..3607c66 --- /dev/null +++ b/apps/native/src/components/ui/SafeView.tsx @@ -0,0 +1,16 @@ +import { SafeAreaView } from 'react-native-safe-area-context'; +import { cn } from '@/lib/utils'; + +interface SafeViewProps { + children: React.ReactNode; + edges?: Array<'top' | 'right' | 'bottom' | 'left'>; + className?: string; +} + +export function SafeView({ children, edges = ['top', 'bottom'], className }: SafeViewProps) { + return ( + + {children} + + ); +} diff --git a/apps/native/src/components/ui/Separator.tsx b/apps/native/src/components/ui/Separator.tsx new file mode 100644 index 0000000..caa4f2f --- /dev/null +++ b/apps/native/src/components/ui/Separator.tsx @@ -0,0 +1,10 @@ +import { View } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface SeparatorProps { + className?: string; +} + +export function Separator({ className }: SeparatorProps) { + return ; +} diff --git a/apps/native/src/components/ui/Switch.tsx b/apps/native/src/components/ui/Switch.tsx new file mode 100644 index 0000000..64eff6f --- /dev/null +++ b/apps/native/src/components/ui/Switch.tsx @@ -0,0 +1,18 @@ +import { Switch as RNSwitch, type SwitchProps as RNSwitchProps } from 'react-native'; + +interface SwitchProps extends RNSwitchProps { + value: boolean; + onValueChange: (value: boolean) => void; +} + +export function Switch({ value, onValueChange, ...props }: SwitchProps) { + return ( + + ); +} diff --git a/apps/native/src/components/ui/Text.tsx b/apps/native/src/components/ui/Text.tsx new file mode 100644 index 0000000..56c85d8 --- /dev/null +++ b/apps/native/src/components/ui/Text.tsx @@ -0,0 +1,26 @@ +import { Text as RNText, type TextProps as RNTextProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'; + +interface TextProps extends RNTextProps { + variant?: TextVariant; + className?: string; +} + +const variantStyles: Record = { + h1: 'text-3xl font-bold text-foreground', + h2: 'text-2xl font-bold text-foreground', + h3: 'text-xl font-semibold text-foreground', + body: 'text-base text-foreground', + caption: 'text-sm text-muted-foreground', + label: 'text-sm font-medium text-foreground', +}; + +export function Text({ variant = 'body', className, children, ...props }: TextProps) { + return ( + + {children} + + ); +} diff --git a/apps/native/src/components/ui/index.ts b/apps/native/src/components/ui/index.ts new file mode 100644 index 0000000..82d7122 --- /dev/null +++ b/apps/native/src/components/ui/index.ts @@ -0,0 +1,11 @@ +export { Button } from './Button'; +export { Card, CardHeader, CardContent, CardFooter } from './Card'; +export { Input } from './Input'; +export { Text } from './Text'; +export { Avatar } from './Avatar'; +export { Badge } from './Badge'; +export { Switch } from './Switch'; +export { Separator } from './Separator'; +export { EmptyState } from './EmptyState'; +export { LoadingSpinner } from './LoadingSpinner'; +export { SafeView } from './SafeView'; diff --git a/apps/native/src/db/index.ts b/apps/native/src/db/index.ts new file mode 100644 index 0000000..746923e --- /dev/null +++ b/apps/native/src/db/index.ts @@ -0,0 +1,9 @@ +import { openDatabaseSync } from 'expo-sqlite'; +import { drizzle } from 'drizzle-orm/expo-sqlite'; +import * as schema from './schema'; + +const sqlite = openDatabaseSync('conpaws.db', { enableChangeListener: true }); + +export const db = drizzle(sqlite, { schema }); + +export type Database = typeof db; diff --git a/apps/native/src/db/repositories/conventions.ts b/apps/native/src/db/repositories/conventions.ts new file mode 100644 index 0000000..b4f9795 --- /dev/null +++ b/apps/native/src/db/repositories/conventions.ts @@ -0,0 +1,34 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../index'; +import { conventions, type Convention, type NewConvention } from '../schema'; + +function generateId(): string { + return 'conv_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +export async function getAll(): Promise { + return db.select().from(conventions).orderBy(conventions.startDate); +} + +export async function getById(id: string): Promise { + const results = await db.select().from(conventions).where(eq(conventions.id, id)); + return results[0]; +} + +export async function create(data: Omit): Promise { + const now = new Date().toISOString(); + const id = generateId(); + await db.insert(conventions).values({ ...data, id, createdAt: now, updatedAt: now }); + return (await getById(id))!; +} + +export async function update(id: string, data: Partial>): Promise { + await db + .update(conventions) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(conventions.id, id)); +} + +export async function remove(id: string): Promise { + await db.delete(conventions).where(eq(conventions.id, id)); +} diff --git a/apps/native/src/db/repositories/events.ts b/apps/native/src/db/repositories/events.ts new file mode 100644 index 0000000..11178d1 --- /dev/null +++ b/apps/native/src/db/repositories/events.ts @@ -0,0 +1,108 @@ +import { eq, inArray } from 'drizzle-orm'; +import { db } from '../index'; +import { conventionEvents, type ConventionEvent, type NewConventionEvent } from '../schema'; + +function generateId(): string { + return 'evt_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +export async function getByConventionId(conventionId: string): Promise { + return db + .select() + .from(conventionEvents) + .where(eq(conventionEvents.conventionId, conventionId)) + .orderBy(conventionEvents.startTime); +} + +export async function getById(id: string): Promise { + const results = await db.select().from(conventionEvents).where(eq(conventionEvents.id, id)); + return results[0]; +} + +export async function batchInsert( + events: Omit[], +): Promise { + if (events.length === 0) return; + const now = new Date().toISOString(); + const rows = events.map((e) => ({ ...e, id: generateId(), createdAt: now, updatedAt: now })); + await db.insert(conventionEvents).values(rows); +} + +export interface UpsertResult { + added: number; + updated: number; +} + +export async function upsertBySourceUid( + events: Omit[], + conventionId: string, +): Promise { + if (events.length === 0) return { added: 0, updated: 0 }; + + const existing = await db + .select() + .from(conventionEvents) + .where(eq(conventionEvents.conventionId, conventionId)); + + const existingByUid = new Map(existing.map((e) => [e.sourceUid, e])); + + let added = 0; + let updated = 0; + const now = new Date().toISOString(); + + for (const event of events) { + const uid = event.sourceUid; + const existingEvent = uid ? existingByUid.get(uid) : undefined; + + if (existingEvent) { + // Preserve user state: isInSchedule and reminderMinutes + await db + .update(conventionEvents) + .set({ + title: event.title, + description: event.description, + startTime: event.startTime, + endTime: event.endTime, + location: event.location, + room: event.room, + category: event.category, + type: event.type, + sourceUrl: event.sourceUrl, + isAgeRestricted: event.isAgeRestricted, + contentWarning: event.contentWarning, + updatedAt: now, + }) + .where(eq(conventionEvents.id, existingEvent.id)); + updated++; + } else { + await db.insert(conventionEvents).values({ + ...event, + id: generateId(), + createdAt: now, + updatedAt: now, + }); + added++; + } + } + + return { added, updated }; +} + +export async function remove(id: string): Promise { + await db.delete(conventionEvents).where(eq(conventionEvents.id, id)); +} + +export async function update(id: string, data: Partial>): Promise { + await db + .update(conventionEvents) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(conventionEvents.id, id)); +} + +export async function getIdsByConventionId(conventionId: string): Promise { + const rows = await db + .select({ id: conventionEvents.id }) + .from(conventionEvents) + .where(eq(conventionEvents.conventionId, conventionId)); + return rows.map((r) => r.id); +} diff --git a/apps/native/src/db/schema.ts b/apps/native/src/db/schema.ts new file mode 100644 index 0000000..be1a81b --- /dev/null +++ b/apps/native/src/db/schema.ts @@ -0,0 +1,47 @@ +import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +export const conventions = sqliteTable('conventions', { + id: text('id').primaryKey(), + name: text('name').notNull(), + startDate: text('start_date').notNull(), + endDate: text('end_date').notNull(), + icalUrl: text('ical_url'), + status: text('status', { enum: ['upcoming', 'active', 'ended'] }).notNull().default('upcoming'), + createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), + updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`), +}); + +export const conventionEvents = sqliteTable('convention_events', { + id: text('id').primaryKey(), + conventionId: text('convention_id') + .notNull() + .references(() => conventions.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description'), + startTime: text('start_time').notNull(), + endTime: text('end_time'), + location: text('location'), + room: text('room'), + category: text('category'), + type: text('type'), + isInSchedule: integer('is_in_schedule', { mode: 'boolean' }).notNull().default(false), + reminderMinutes: integer('reminder_minutes'), + sourceUid: text('source_uid'), + sourceUrl: text('source_url'), + isAgeRestricted: integer('is_age_restricted', { mode: 'boolean' }).notNull().default(false), + contentWarning: integer('content_warning', { mode: 'boolean' }).notNull().default(false), + createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), + updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`), +}); + +export const offlineQueue = sqliteTable('offline_queue', { + id: text('id').primaryKey(), + payload: text('payload').notNull(), + createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), +}); + +export type Convention = typeof conventions.$inferSelect; +export type NewConvention = typeof conventions.$inferInsert; +export type ConventionEvent = typeof conventionEvents.$inferSelect; +export type NewConventionEvent = typeof conventionEvents.$inferInsert; diff --git a/apps/native/src/global.css b/apps/native/src/global.css index 600d8c7..127af22 100644 --- a/apps/native/src/global.css +++ b/apps/native/src/global.css @@ -1,4 +1,7 @@ -@import "tailwindcss"; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/preflight.css" layer(base); +@import "tailwindcss/utilities.css"; +@import "nativewind/theme"; @theme { --color-primary: #0FACED; diff --git a/apps/native/src/hooks/useEventReminder.ts b/apps/native/src/hooks/useEventReminder.ts new file mode 100644 index 0000000..c050944 --- /dev/null +++ b/apps/native/src/hooks/useEventReminder.ts @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Haptics from 'expo-haptics'; +import { + requestNotificationPermission, + getNotificationPermissionStatus, + scheduleEventReminder, + cancelEventReminder, +} from '@/services/notifications'; +import * as eventsRepo from '@/db/repositories/events'; +import type { ConventionEvent } from '@/db/schema'; + +const NOTIFICATIONS_REQUESTED_KEY = 'hasRequestedNotifications'; + +export function useEventReminder(event: ConventionEvent) { + const [showPrePrompt, setShowPrePrompt] = useState(false); + const [pendingMinutes, setPendingMinutes] = useState(null); + + async function setReminder(minutes: number): Promise<{ denied?: boolean }> { + if (minutes === 0) { + await clearReminder(); + return {}; + } + + const hasRequested = await AsyncStorage.getItem(NOTIFICATIONS_REQUESTED_KEY); + + if (!hasRequested) { + // Show custom pre-prompt + setPendingMinutes(minutes); + setShowPrePrompt(true); + return {}; + } + + return _doSetReminder(minutes); + } + + async function _doSetReminder(minutes: number): Promise<{ denied?: boolean }> { + const permission = await getNotificationPermissionStatus(); + + if (permission === 'undetermined') { + await AsyncStorage.setItem(NOTIFICATIONS_REQUESTED_KEY, 'true'); + const result = await requestNotificationPermission(); + if (result !== 'granted') { + return { denied: true }; + } + } else if (permission === 'denied') { + return { denied: true }; + } + + // Cancel existing first + await cancelEventReminder(event.id); + + // Schedule new + await scheduleEventReminder( + { + id: event.id, + title: event.title, + startTime: event.startTime, + room: event.room, + }, + minutes, + ); + + // Persist to DB + await eventsRepo.update(event.id, { reminderMinutes: minutes }); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + + return {}; + } + + async function handlePrePromptEnable() { + setShowPrePrompt(false); + await AsyncStorage.setItem(NOTIFICATIONS_REQUESTED_KEY, 'true'); + if (pendingMinutes !== null) { + await _doSetReminder(pendingMinutes); + setPendingMinutes(null); + } + } + + function handlePrePromptDismiss() { + setShowPrePrompt(false); + setPendingMinutes(null); + } + + async function clearReminder(): Promise { + await cancelEventReminder(event.id); + await eventsRepo.update(event.id, { reminderMinutes: null }); + } + + return { + setReminder, + clearReminder, + hasReminder: event.reminderMinutes !== null, + reminderMinutes: event.reminderMinutes, + showPrePrompt, + handlePrePromptEnable, + handlePrePromptDismiss, + }; +} diff --git a/apps/native/src/hooks/useImportSchedule.ts b/apps/native/src/hooks/useImportSchedule.ts new file mode 100644 index 0000000..f451d69 --- /dev/null +++ b/apps/native/src/hooks/useImportSchedule.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import * as eventsRepo from '@/db/repositories/events'; +import type { ParsedEvent } from '@/lib/ical-parser'; + +export interface ImportInput { + parsedEvents: ParsedEvent[]; + conventionId: string; +} + +export interface ImportResult { + added: number; + updated: number; +} + +export function useImportSchedule() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ parsedEvents, conventionId }) => { + const mapped = parsedEvents.map((e) => ({ + conventionId, + title: e.title, + description: e.description, + startTime: e.startTime.toISOString(), + endTime: e.endTime?.toISOString() ?? null, + location: e.location, + room: e.room, + category: e.category, + type: null, + isInSchedule: false, + reminderMinutes: null, + sourceUid: e.sourceUid, + sourceUrl: e.sourceUrl, + isAgeRestricted: e.isAgeRestricted, + contentWarning: e.contentWarning, + })); + + return eventsRepo.upsertBySourceUid(mapped, conventionId); + }, + onSuccess: (_data, { conventionId }) => { + queryClient.invalidateQueries({ queryKey: ['events', conventionId] }); + }, + }); +} diff --git a/apps/native/src/lib/__tests__/ical-parser.test.ts b/apps/native/src/lib/__tests__/ical-parser.test.ts new file mode 100644 index 0000000..86f3597 --- /dev/null +++ b/apps/native/src/lib/__tests__/ical-parser.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { parseIcs } from '../ical-parser'; + +const SMALL_ICS_PATH = path.resolve(__dirname, '../../../../../test-data/small-test.ics'); + +function loadSmallIcs(): string { + return fs.readFileSync(SMALL_ICS_PATH, 'utf-8'); +} + +describe('parseIcs', () => { + it('returns empty result for empty input', () => { + const result = parseIcs(''); + expect(result.events).toHaveLength(0); + expect(result.categories).toHaveLength(0); + expect(result.timezone).toBeNull(); + }); + + it('parses all events from small-test.ics', () => { + const result = parseIcs(loadSmallIcs()); + expect(result.events).toHaveLength(10); + }); + + it('parses basic event fields (title, uid)', () => { + const result = parseIcs(loadSmallIcs()); + const opening = result.events.find((e) => e.sourceUid === 'test-opening-001'); + expect(opening).toBeDefined(); + expect(opening!.title).toBe('Opening Ceremonies'); + expect(opening!.sourceUid).toBe('test-opening-001'); + }); + + it('parses UTC datetime correctly', () => { + const result = parseIcs(loadSmallIcs()); + const opening = result.events.find((e) => e.sourceUid === 'test-opening-001'); + expect(opening).toBeDefined(); + // 20260612T160000Z = June 12, 2026 16:00 UTC + expect(opening!.startTime.getUTCFullYear()).toBe(2026); + expect(opening!.startTime.getUTCMonth()).toBe(5); // 0-indexed + expect(opening!.startTime.getUTCDate()).toBe(12); + expect(opening!.startTime.getUTCHours()).toBe(16); + }); + + it('parses event source URL', () => { + const result = parseIcs(loadSmallIcs()); + const opening = result.events.find((e) => e.sourceUid === 'test-opening-001'); + expect(opening!.sourceUrl).toBe('https://testcon2026.sched.com/event/test-opening-001'); + }); + + it('unescapes \\n in description', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612T160000Z +DTEND:20260612T170000Z +SUMMARY:Test Event +DESCRIPTION:Line 1\\nLine 2\\nLine 3 +UID:test-escape-newline +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + expect(result.events[0].description).toContain('\n'); + expect(result.events[0].description).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('unescapes \\, in description', () => { + const result = parseIcs(loadSmallIcs()); + // "Calling all wolves\\, dogs\\, foxes" should become "Calling all wolves, dogs, foxes" + const canine = result.events.find((e) => e.sourceUid === 'test-canine-004'); + expect(canine!.description).toContain('wolves, dogs'); + }); + + it('decodes HTML entities in description', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612T160000Z +DTEND:20260612T170000Z +SUMMARY:Test & Event +DESCRIPTION:Foxes & wolves <3 +UID:test-html-entities +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + expect(result.events[0].title).toBe('Test & Event'); + expect(result.events[0].description).toBe('Foxes & wolves <3'); + }); + + it('handles line folding (multi-line values)', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612T160000Z +DTEND:20260612T170000Z +SUMMARY:Folded + Line Title +DESCRIPTION:This is a very long description that gets + folded onto multiple lines +UID:test-folding +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + // RFC 5545: unfolding removes the CRLF+WSP, so the space is the fold indicator not content + expect(result.events[0].title).toBe('FoldedLine Title'); + expect(result.events[0].description).toContain('folded onto multiple lines'); + }); + + it('splits location into room and location', () => { + const result = parseIcs(loadSmallIcs()); + const opening = result.events.find((e) => e.sourceUid === 'test-opening-001'); + // LOCATION:Main Stage\, Convention Center → room="Main Stage", location="Convention Center" + expect(opening!.room).toBe('Main Stage'); + expect(opening!.location).toBe('Convention Center'); + }); + + it('deduplicates categories and assigns colors', () => { + const result = parseIcs(loadSmallIcs()); + // CONVENTION SERVICES appears for opening and closing (2 events) + const convServices = result.categories.find((c) => c.name === 'CONVENTION SERVICES'); + expect(convServices).toBeDefined(); + expect(convServices!.count).toBe(2); + expect(convServices!.color).toMatch(/^#[0-9A-Fa-f]{6}$/); + + // Each category should be unique + const names = result.categories.map((c) => c.name); + expect(names).toHaveLength(new Set(names).size); + }); + + it('detects isAgeRestricted from title/description', () => { + const result = parseIcs(loadSmallIcs()); + // test-trivia-009: "After Dark Trivia" with "18+ ONLY" in description + const trivia = result.events.find((e) => e.sourceUid === 'test-trivia-009'); + expect(trivia!.isAgeRestricted).toBe(true); + }); + + it('detects contentWarning for strobe effects', () => { + const result = parseIcs(loadSmallIcs()); + // test-dance-005: "Friday Night Dance" with "strobe effects" in description + const dance = result.events.find((e) => e.sourceUid === 'test-dance-005'); + expect(dance!.contentWarning).toBe(true); + }); + + it('handles missing optional fields gracefully', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612T160000Z +SUMMARY:Minimal Event +UID:test-minimal +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + expect(result.events).toHaveLength(1); + const ev = result.events[0]; + expect(ev.description).toBeNull(); + expect(ev.endTime).toBeNull(); + expect(ev.location).toBeNull(); + expect(ev.room).toBeNull(); + expect(ev.category).toBeNull(); + expect(ev.sourceUrl).toBeNull(); + expect(ev.isAgeRestricted).toBe(false); + expect(ev.contentWarning).toBe(false); + }); + + it('deduplicates events with the same UID', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612T160000Z +SUMMARY:Duplicate Event +UID:test-dup-001 +END:VEVENT +BEGIN:VEVENT +DTSTART:20260612T170000Z +SUMMARY:Duplicate Event Copy +UID:test-dup-001 +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + expect(result.events).toHaveLength(1); + expect(result.events[0].title).toBe('Duplicate Event'); + }); + + it('parses all-day event without time component', () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +DTSTART:20260612 +DTEND:20260613 +SUMMARY:All Day Event +UID:test-allday +END:VEVENT +END:VCALENDAR`; + const result = parseIcs(ics); + expect(result.events).toHaveLength(1); + const ev = result.events[0]; + expect(ev.startTime.getFullYear()).toBe(2026); + expect(ev.startTime.getMonth()).toBe(5); + expect(ev.startTime.getDate()).toBe(12); + }); +}); diff --git a/apps/native/src/lib/i18n.ts b/apps/native/src/lib/i18n.ts new file mode 100644 index 0000000..d3b6030 --- /dev/null +++ b/apps/native/src/lib/i18n.ts @@ -0,0 +1,64 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import * as Localization from 'expo-localization'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import en from '../locales/en.json'; +import es from '../locales/es.json'; +import nl from '../locales/nl.json'; +import de from '../locales/de.json'; +import fr from '../locales/fr.json'; +import ptBR from '../locales/pt-BR.json'; +import sv from '../locales/sv.json'; +import pl from '../locales/pl.json'; + +export const SUPPORTED_LANGUAGES = ['en', 'es', 'nl', 'de', 'fr', 'pt-BR', 'sv', 'pl'] as const; +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +export const LANGUAGE_META: Record< + SupportedLanguage, + { nativeName: string; flag: string } +> = { + en: { nativeName: 'English', flag: '🇬🇧' }, + es: { nativeName: 'Español', flag: '🇪🇸' }, + nl: { nativeName: 'Nederlands', flag: '🇳🇱' }, + de: { nativeName: 'Deutsch', flag: '🇩🇪' }, + fr: { nativeName: 'Français', flag: '🇫🇷' }, + 'pt-BR': { nativeName: 'Português', flag: '🇧🇷' }, + sv: { nativeName: 'Svenska', flag: '🇸🇪' }, + pl: { nativeName: 'Polski', flag: '🇵🇱' }, +}; + +export async function initI18n(): Promise { + const saved = (await AsyncStorage.getItem('appLanguage')) as SupportedLanguage | null; + const deviceCode = Localization.getLocales()[0]?.languageCode ?? 'en'; + const deviceLang = SUPPORTED_LANGUAGES.find( + (l) => l === deviceCode || l.startsWith(deviceCode) + ); + const lng = saved ?? deviceLang ?? 'en'; + + await i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + es: { translation: es }, + nl: { translation: nl }, + de: { translation: de }, + fr: { translation: fr }, + 'pt-BR': { translation: ptBR }, + sv: { translation: sv }, + pl: { translation: pl }, + }, + lng, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); +} + +export async function changeLanguage(code: SupportedLanguage): Promise { + await AsyncStorage.setItem('appLanguage', code); + await i18n.changeLanguage(code); +} + +export default i18n; diff --git a/apps/native/src/lib/ical-parser.ts b/apps/native/src/lib/ical-parser.ts new file mode 100644 index 0000000..1636d0d --- /dev/null +++ b/apps/native/src/lib/ical-parser.ts @@ -0,0 +1,261 @@ +export interface ParsedEvent { + title: string; + description: string | null; + startTime: Date; + endTime: Date | null; + location: string | null; + room: string | null; + category: string | null; + sourceUid: string; + sourceUrl: string | null; + isAgeRestricted: boolean; + contentWarning: boolean; +} + +export interface CategoryMeta { + name: string; + count: number; + color: string; +} + +export interface ParseResult { + timezone: string | null; + events: ParsedEvent[]; + categories: CategoryMeta[]; +} + +const CATEGORY_PALETTE = [ + '#FF6B6B', '#FFA500', '#FFD700', '#7ED321', '#4CAF50', + '#0FACED', '#5B9BD5', '#9B59B6', '#E91E63', '#00BCD4', + '#FF5722', '#795548', '#607D8B', '#3F51B5', '#009688', +]; + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function categoryColor(name: string): string { + return CATEGORY_PALETTE[hashString(name) % CATEGORY_PALETTE.length]; +} + +/** Unfold RFC 5545 line continuations (CRLF/LF followed by space/tab) */ +function unfold(raw: string): string { + return raw.replace(/\r?\n[ \t]/g, ''); +} + +/** Unescape iCal text: \n → newline, \, → comma, \; → semicolon, \\ → backslash */ +function unescapeText(text: string): string { + return text + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +/** Decode common HTML entities */ +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/ /gi, ' ') + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))); +} + +/** Parse iCal datetime string to Date. + * Handles: YYYYMMDDTHHMMSSZ (UTC), YYYYMMDDTHHMMSS (local), YYYYMMDD (all-day) + */ +function parseDateTime(value: string): Date | null { + // Strip any TZID parameter prefix if present in the value (rare but possible) + const val = value.split(':').pop() ?? value; + + // UTC: 20260612T160000Z + const utcMatch = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (utcMatch) { + const [, y, mo, d, h, mi, s] = utcMatch; + return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)); + } + + // Local datetime: 20260612T160000 + const localMatch = val.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/); + if (localMatch) { + const [, y, mo, d, h, mi, s] = localMatch; + return new Date(+y, +mo - 1, +d, +h, +mi, +s); + } + + // All-day: 20260612 + const dateMatch = val.match(/^(\d{4})(\d{2})(\d{2})$/); + if (dateMatch) { + const [, y, mo, d] = dateMatch; + return new Date(+y, +mo - 1, +d, 0, 0, 0); + } + + return null; +} + +/** Split "Room Name, Venue Name" → { location: "Venue Name", room: "Room Name" } + * Split on LAST comma so "Panel Room A, Convention Center" → room="Panel Room A", location="Convention Center" + */ +function splitLocation(raw: string): { location: string; room: string | null } { + const lastComma = raw.lastIndexOf(','); + if (lastComma === -1) return { location: raw.trim(), room: null }; + const room = raw.slice(0, lastComma).trim(); + const location = raw.slice(lastComma + 1).trim(); + return { location, room }; +} + +function detectAgeRestricted(text: string): boolean { + const lower = text.toLowerCase(); + return /18\+|nsfw|adult|after dark/.test(lower); +} + +function detectContentWarning(text: string): boolean { + const lower = text.toLowerCase(); + return /strobe|flash warning/.test(lower); +} + +/** Extract the value from a property line, handling PARAM;KEY=VAL:VALUE format */ +function extractValue(line: string): string { + // Find the colon that separates params from value (not inside quotes) + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') inQuote = !inQuote; + if (ch === ':' && !inQuote) return line.slice(i + 1); + } + return ''; +} + +/** Extract just the property name (before any ; or :) */ +function extractPropName(line: string): string { + const semi = line.indexOf(';'); + const colon = line.indexOf(':'); + const end = semi !== -1 && semi < colon ? semi : colon; + return end !== -1 ? line.slice(0, end) : line; +} + +export function parseIcs(raw: string): ParseResult { + if (!raw || raw.trim().length === 0) { + return { timezone: null, events: [], categories: [] }; + } + + const unfolded = unfold(raw); + const lines = unfolded.split(/\r?\n/); + + // Extract calendar-level timezone + let timezone: string | null = null; + for (const line of lines) { + if (line.startsWith('X-WR-TIMEZONE:')) { + timezone = line.slice('X-WR-TIMEZONE:'.length).trim(); + break; + } + } + + // Split into VEVENT blocks + const eventBlocks: string[][] = []; + let currentBlock: string[] | null = null; + + for (const line of lines) { + if (line === 'BEGIN:VEVENT') { + currentBlock = []; + } else if (line === 'END:VEVENT') { + if (currentBlock) { + eventBlocks.push(currentBlock); + currentBlock = null; + } + } else if (currentBlock !== null) { + currentBlock.push(line); + } + } + + const seenUids = new Set(); + const parsedEvents: ParsedEvent[] = []; + const categoryCountMap = new Map(); + + for (const block of eventBlocks) { + const props: Record = {}; + + for (const line of block) { + if (!line) continue; + const propName = extractPropName(line); + const value = extractValue(line); + // For DTSTART/DTEND, keep the whole line name (may include TZID param) + props[propName] = value; + } + + const uid = props['UID']; + if (!uid) continue; + if (seenUids.has(uid)) continue; + seenUids.add(uid); + + const rawSummary = props['SUMMARY'] ?? ''; + const title = decodeHtmlEntities(unescapeText(rawSummary)).trim(); + if (!title) continue; + + const rawDesc = props['DESCRIPTION'] ?? null; + const description = rawDesc + ? decodeHtmlEntities(unescapeText(rawDesc)).trim() || null + : null; + + const startTime = parseDateTime(props['DTSTART'] ?? ''); + if (!startTime) continue; + + const endTime = parseDateTime(props['DTEND'] ?? '') ?? null; + + const rawLocation = props['LOCATION'] ?? null; + let location: string | null = null; + let room: string | null = null; + if (rawLocation) { + const split = splitLocation(decodeHtmlEntities(unescapeText(rawLocation))); + location = split.location; + room = split.room; + } + + const rawCategory = props['CATEGORIES'] ?? null; + const category = rawCategory + ? decodeHtmlEntities(unescapeText(rawCategory)).split(',')[0].trim() || null + : null; + + const sourceUrl = props['URL'] ?? null; + + const checkText = `${title} ${description ?? ''}`; + const isAgeRestricted = detectAgeRestricted(checkText); + const contentWarning = detectContentWarning(checkText); + + if (category) { + categoryCountMap.set(category, (categoryCountMap.get(category) ?? 0) + 1); + } + + parsedEvents.push({ + title, + description, + startTime, + endTime, + location, + room, + category, + sourceUid: uid, + sourceUrl, + isAgeRestricted, + contentWarning, + }); + } + + // Sort by start time + parsedEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + const categories: CategoryMeta[] = Array.from(categoryCountMap.entries()).map(([name, count]) => ({ + name, + count, + color: categoryColor(name), + })); + + return { timezone, events: parsedEvents, categories }; +} diff --git a/apps/native/src/lib/sched-extractor.ts b/apps/native/src/lib/sched-extractor.ts new file mode 100644 index 0000000..8e71e4f --- /dev/null +++ b/apps/native/src/lib/sched-extractor.ts @@ -0,0 +1,64 @@ +export class InvalidSchedUrlError extends Error { + constructor(url: string) { + super(`Invalid Sched URL: "${url}". Expected format: https://yourcon.sched.com`); + this.name = 'InvalidSchedUrlError'; + } +} + +export class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkError'; + } +} + +export class InvalidResponseError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidResponseError'; + } +} + +const SCHED_PATTERN = /^https?:\/\/([a-zA-Z0-9-]+)\.sched\.com(\/.*)?$/; + +export async function fetchSchedIcs(url: string): Promise { + const trimmed = url.trim(); + const match = trimmed.match(SCHED_PATTERN); + + if (!match) { + throw new InvalidSchedUrlError(trimmed); + } + + const subdomain = match[1]; + const icsUrl = `https://${subdomain}.sched.com/all.ics`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch(icsUrl, { signal: controller.signal }); + + if (!response.ok) { + throw new NetworkError(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.startsWith('text/calendar')) { + throw new InvalidResponseError( + `Expected text/calendar response, got: ${contentType}`, + ); + } + + return await response.text(); + } catch (err) { + if (err instanceof InvalidSchedUrlError || err instanceof InvalidResponseError) { + throw err; + } + if ((err as Error).name === 'AbortError') { + throw new NetworkError('Request timed out after 30 seconds'); + } + throw new NetworkError(`Network request failed: ${(err as Error).message}`); + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/native/src/locales/de.json b/apps/native/src/locales/de.json new file mode 100644 index 0000000..893393c --- /dev/null +++ b/apps/native/src/locales/de.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Startseite", + "profile": "Profil", + "settings": "Einstellungen", + "back": "Zurück", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "done": "Fertig", + "loading": "Laden...", + "error": "Etwas ist schiefgelaufen", + "retry": "Erneut versuchen", + "close": "Schließen", + "next": "Weiter", + "skip": "Überspringen", + "getStarted": "Los geht's", + "learnMore": "Mehr erfahren", + "version": "Version {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navigieren, Verbinden, Genießen", + "subtitle": "Dein Furry-Convention-Begleiter", + "getStarted": "Los geht's" + }, + "features": { + "title": "Alles, was du brauchst", + "subtitle": "ConPaws hält dein Convention-Erlebnis organisiert", + "calendar": { + "title": "Convention-Kalender", + "description": "Importiere Zeitpläne von jeder Convention. Sieh Panels, Events und Aktivitäten auf einen Blick." + }, + "share": { + "title": "Teilen & Verbinden", + "description": "Erstelle deinen persönlichen Zeitplan und teile ihn mit Freunden, die zur gleichen Con gehen." + }, + "offline": { + "title": "Funktioniert offline", + "description": "Alle deine Daten werden lokal gespeichert. Kein Internet erforderlich, sobald du deinen Zeitplan importiert hast." + }, + "next": "Weiter" + }, + "getStarted": { + "title": "ConPaws beitreten", + "subtitle": "Melde dich an, um deinen Zeitplan geräteübergreifend zu synchronisieren", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "skipForNow": "Jetzt überspringen", + "legal": "Durch Fortfahren stimmst du unseren Nutzungsbedingungen und der Datenschutzerklärung zu" + }, + "complete": { + "title": "Alles bereit!", + "subtitle": "Willkommen bei ConPaws. Lass uns deine erste Convention erkunden.", + "letsGo": "Los geht's" + } + }, + "home": { + "title": "Meine Conventions", + "addConvention": "Convention hinzufügen", + "empty": { + "title": "Noch keine Conventions", + "subtitle": "Füge deine erste Convention hinzu, um loszulegen.", + "cta": "Convention hinzufügen" + }, + "upcoming": "Bevorstehend", + "active": "Aktiv", + "past": "Vergangen" + }, + "convention": { + "new": "Neue Convention", + "edit": "Convention bearbeiten", + "import": "Zeitplan importieren", + "detail": "Convention-Details", + "events": "Events", + "noEvents": "Noch keine Events", + "noEventsSubtitle": "Importiere einen Zeitplan oder füge Events manuell hinzu.", + "addEvent": "Event hinzufügen", + "importSchedule": "Zeitplan importieren", + "startDate": "Startdatum", + "endDate": "Enddatum", + "name": "Convention-Name", + "namePlaceholder": "z.B. IndyFurCon 2025" + }, + "profile": { + "title": "Profil", + "signIn": "Anmelden", + "notSignedIn": "Nicht angemeldet", + "notSignedInSubtitle": "Melde dich an, um deinen Zeitplan zu synchronisieren und Freunde zu verbinden.", + "comingSoon": "Profilfunktionen kommen bald", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden" + }, + "settings": { + "title": "Einstellungen", + "languages": { + "title": "Sprache", + "systemDefault": "Gerätesprache verwenden", + "en": "Englisch", + "es": "Spanisch", + "nl": "Niederländisch", + "de": "Deutsch", + "fr": "Französisch", + "pt-BR": "Portugiesisch (Brasilien)", + "sv": "Schwedisch", + "pl": "Polnisch" + }, + "account": { + "title": "Konto", + "signIn": "Anmelden", + "signOut": "Abmelden" + }, + "app": { + "title": "App", + "theme": "Design", + "language": "Sprache", + "notifications": "Benachrichtigungen" + }, + "data": { + "title": "Daten", + "exportData": "Daten exportieren", + "importData": "Daten importieren", + "resetOnboarding": "Onboarding zurücksetzen" + }, + "legal": { + "title": "Rechtliches", + "privacyPolicy": "Datenschutzerklärung", + "termsOfService": "Nutzungsbedingungen", + "about": "Über ConPaws", + "openSourceLicenses": "Open-Source-Lizenzen" + }, + "about": { + "title": "Über", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Zeitplan importieren", + "url": "Von URL importieren", + "file": "Von Datei importieren", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Events vorschau", + "importing": "Importieren...", + "success": "Import abgeschlossen", + "error": "Import fehlgeschlagen" + }, + "reminders": { + "title": "Erinnerungen", + "add": "Erinnerung hinzufügen", + "minutesBefore": "{{minutes}} Minuten vorher", + "atTime": "Zur Eventzeit", + "none": "Keine Erinnerung" + } +} diff --git a/apps/native/src/locales/en.json b/apps/native/src/locales/en.json new file mode 100644 index 0000000..e12a5f9 --- /dev/null +++ b/apps/native/src/locales/en.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Home", + "profile": "Profile", + "settings": "Settings", + "back": "Back", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "done": "Done", + "loading": "Loading...", + "error": "Something went wrong", + "retry": "Retry", + "close": "Close", + "next": "Next", + "skip": "Skip", + "getStarted": "Get Started", + "learnMore": "Learn More", + "version": "Version {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navigate, Connect, Enjoy", + "subtitle": "Your furry convention companion", + "getStarted": "Get Started" + }, + "features": { + "title": "Everything you need", + "subtitle": "ConPaws keeps your convention experience organized", + "calendar": { + "title": "Convention Calendar", + "description": "Import schedules from any convention. Browse panels, events, and activities at a glance." + }, + "share": { + "title": "Share & Connect", + "description": "Build your personal schedule and share it with friends attending the same con." + }, + "offline": { + "title": "Works Offline", + "description": "All your data is stored locally. No internet required once you've imported your schedule." + }, + "next": "Next" + }, + "getStarted": { + "title": "Join ConPaws", + "subtitle": "Sign in to sync your schedule across devices", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "skipForNow": "Skip for now", + "legal": "By continuing, you agree to our Terms of Service and Privacy Policy" + }, + "complete": { + "title": "You're all set!", + "subtitle": "Welcome to ConPaws. Let's explore your first convention.", + "letsGo": "Let's Go" + } + }, + "home": { + "title": "My Conventions", + "addConvention": "Add Convention", + "empty": { + "title": "No conventions yet", + "subtitle": "Add your first convention to get started.", + "cta": "Add Convention" + }, + "upcoming": "Upcoming", + "active": "Active", + "past": "Past" + }, + "convention": { + "new": "New Convention", + "edit": "Edit Convention", + "import": "Import Schedule", + "detail": "Convention Details", + "events": "Events", + "noEvents": "No events yet", + "noEventsSubtitle": "Import a schedule or add events manually.", + "addEvent": "Add Event", + "importSchedule": "Import Schedule", + "startDate": "Start Date", + "endDate": "End Date", + "name": "Convention Name", + "namePlaceholder": "e.g. IndyFurCon 2025" + }, + "profile": { + "title": "Profile", + "signIn": "Sign In", + "notSignedIn": "Not signed in", + "notSignedInSubtitle": "Sign in to sync your schedule and connect with friends.", + "comingSoon": "Profile features coming soon", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google" + }, + "settings": { + "title": "Settings", + "languages": { + "title": "Language", + "systemDefault": "Use device language", + "en": "English", + "es": "Spanish", + "nl": "Dutch", + "de": "German", + "fr": "French", + "pt-BR": "Portuguese (Brazil)", + "sv": "Swedish", + "pl": "Polish" + }, + "account": { + "title": "Account", + "signIn": "Sign In", + "signOut": "Sign Out" + }, + "app": { + "title": "App", + "theme": "Theme", + "language": "Language", + "notifications": "Notifications" + }, + "data": { + "title": "Data", + "exportData": "Export Data", + "importData": "Import Data", + "resetOnboarding": "Reset Onboarding" + }, + "legal": { + "title": "Legal", + "privacyPolicy": "Privacy Policy", + "termsOfService": "Terms of Service", + "about": "About ConPaws", + "openSourceLicenses": "Open Source Licenses" + }, + "about": { + "title": "About", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Import Schedule", + "url": "Import from URL", + "file": "Import from File", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Preview Events", + "importing": "Importing...", + "success": "Import complete", + "error": "Import failed" + }, + "reminders": { + "title": "Reminders", + "add": "Add Reminder", + "minutesBefore": "{{minutes}} minutes before", + "atTime": "At event time", + "none": "No reminder" + } +} diff --git a/apps/native/src/locales/es.json b/apps/native/src/locales/es.json new file mode 100644 index 0000000..35097ac --- /dev/null +++ b/apps/native/src/locales/es.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Inicio", + "profile": "Perfil", + "settings": "Ajustes", + "back": "Atrás", + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "done": "Hecho", + "loading": "Cargando...", + "error": "Algo salió mal", + "retry": "Reintentar", + "close": "Cerrar", + "next": "Siguiente", + "skip": "Omitir", + "getStarted": "Comenzar", + "learnMore": "Saber más", + "version": "Versión {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navega, Conecta, Disfruta", + "subtitle": "Tu compañero de convenciones furry", + "getStarted": "Comenzar" + }, + "features": { + "title": "Todo lo que necesitas", + "subtitle": "ConPaws mantiene tu experiencia en la convención organizada", + "calendar": { + "title": "Calendario de Convenciones", + "description": "Importa horarios de cualquier convención. Consulta paneles, eventos y actividades de un vistazo." + }, + "share": { + "title": "Comparte y Conecta", + "description": "Crea tu horario personal y compártelo con amigos que asistan a la misma con." + }, + "offline": { + "title": "Funciona sin Conexión", + "description": "Todos tus datos se almacenan localmente. No necesitas internet una vez que hayas importado tu horario." + }, + "next": "Siguiente" + }, + "getStarted": { + "title": "Únete a ConPaws", + "subtitle": "Inicia sesión para sincronizar tu horario entre dispositivos", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "skipForNow": "Omitir por ahora", + "legal": "Al continuar, aceptas nuestros Términos de Servicio y Política de Privacidad" + }, + "complete": { + "title": "¡Todo listo!", + "subtitle": "Bienvenido a ConPaws. Exploremos tu primera convención.", + "letsGo": "¡Vamos!" + } + }, + "home": { + "title": "Mis Convenciones", + "addConvention": "Añadir Convención", + "empty": { + "title": "Sin convenciones aún", + "subtitle": "Añade tu primera convención para comenzar.", + "cta": "Añadir Convención" + }, + "upcoming": "Próximas", + "active": "Activas", + "past": "Pasadas" + }, + "convention": { + "new": "Nueva Convención", + "edit": "Editar Convención", + "import": "Importar Horario", + "detail": "Detalles de la Convención", + "events": "Eventos", + "noEvents": "Sin eventos aún", + "noEventsSubtitle": "Importa un horario o añade eventos manualmente.", + "addEvent": "Añadir Evento", + "importSchedule": "Importar Horario", + "startDate": "Fecha de Inicio", + "endDate": "Fecha de Fin", + "name": "Nombre de la Convención", + "namePlaceholder": "ej. IndyFurCon 2025" + }, + "profile": { + "title": "Perfil", + "signIn": "Iniciar Sesión", + "notSignedIn": "No has iniciado sesión", + "notSignedInSubtitle": "Inicia sesión para sincronizar tu horario y conectarte con amigos.", + "comingSoon": "Funciones de perfil próximamente", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google" + }, + "settings": { + "title": "Ajustes", + "languages": { + "title": "Idioma", + "systemDefault": "Usar idioma del dispositivo", + "en": "Inglés", + "es": "Español", + "nl": "Neerlandés", + "de": "Alemán", + "fr": "Francés", + "pt-BR": "Portugués (Brasil)", + "sv": "Sueco", + "pl": "Polaco" + }, + "account": { + "title": "Cuenta", + "signIn": "Iniciar Sesión", + "signOut": "Cerrar Sesión" + }, + "app": { + "title": "Aplicación", + "theme": "Tema", + "language": "Idioma", + "notifications": "Notificaciones" + }, + "data": { + "title": "Datos", + "exportData": "Exportar Datos", + "importData": "Importar Datos", + "resetOnboarding": "Restablecer Incorporación" + }, + "legal": { + "title": "Legal", + "privacyPolicy": "Política de Privacidad", + "termsOfService": "Términos de Servicio", + "about": "Acerca de ConPaws", + "openSourceLicenses": "Licencias de Código Abierto" + }, + "about": { + "title": "Acerca de", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Importar Horario", + "url": "Importar desde URL", + "file": "Importar desde Archivo", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Vista Previa de Eventos", + "importing": "Importando...", + "success": "Importación completa", + "error": "Error al importar" + }, + "reminders": { + "title": "Recordatorios", + "add": "Añadir Recordatorio", + "minutesBefore": "{{minutes}} minutos antes", + "atTime": "A la hora del evento", + "none": "Sin recordatorio" + } +} diff --git a/apps/native/src/locales/fr.json b/apps/native/src/locales/fr.json new file mode 100644 index 0000000..197ff3c --- /dev/null +++ b/apps/native/src/locales/fr.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Accueil", + "profile": "Profil", + "settings": "Paramètres", + "back": "Retour", + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "done": "Terminé", + "loading": "Chargement...", + "error": "Une erreur s'est produite", + "retry": "Réessayer", + "close": "Fermer", + "next": "Suivant", + "skip": "Passer", + "getStarted": "Commencer", + "learnMore": "En savoir plus", + "version": "Version {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Naviguer, Connecter, Profiter", + "subtitle": "Votre compagnon de convention furry", + "getStarted": "Commencer" + }, + "features": { + "title": "Tout ce dont vous avez besoin", + "subtitle": "ConPaws organise votre expérience de convention", + "calendar": { + "title": "Calendrier de Convention", + "description": "Importez les programmes de n'importe quelle convention. Parcourez les panels, événements et activités en un coup d'œil." + }, + "share": { + "title": "Partager & Connecter", + "description": "Créez votre programme personnel et partagez-le avec des amis qui participent à la même con." + }, + "offline": { + "title": "Fonctionne Hors Ligne", + "description": "Toutes vos données sont stockées localement. Pas d'internet requis une fois votre programme importé." + }, + "next": "Suivant" + }, + "getStarted": { + "title": "Rejoindre ConPaws", + "subtitle": "Connectez-vous pour synchroniser votre programme sur tous vos appareils", + "signInWithApple": "Se connecter avec Apple", + "signInWithGoogle": "Se connecter avec Google", + "skipForNow": "Passer pour l'instant", + "legal": "En continuant, vous acceptez nos Conditions d'utilisation et notre Politique de confidentialité" + }, + "complete": { + "title": "Vous êtes prêt !", + "subtitle": "Bienvenue sur ConPaws. Explorons votre première convention.", + "letsGo": "Allons-y" + } + }, + "home": { + "title": "Mes Conventions", + "addConvention": "Ajouter une Convention", + "empty": { + "title": "Aucune convention pour l'instant", + "subtitle": "Ajoutez votre première convention pour commencer.", + "cta": "Ajouter une Convention" + }, + "upcoming": "À venir", + "active": "Active", + "past": "Passée" + }, + "convention": { + "new": "Nouvelle Convention", + "edit": "Modifier la Convention", + "import": "Importer le Programme", + "detail": "Détails de la Convention", + "events": "Événements", + "noEvents": "Aucun événement pour l'instant", + "noEventsSubtitle": "Importez un programme ou ajoutez des événements manuellement.", + "addEvent": "Ajouter un Événement", + "importSchedule": "Importer le Programme", + "startDate": "Date de Début", + "endDate": "Date de Fin", + "name": "Nom de la Convention", + "namePlaceholder": "ex. IndyFurCon 2025" + }, + "profile": { + "title": "Profil", + "signIn": "Se Connecter", + "notSignedIn": "Non connecté", + "notSignedInSubtitle": "Connectez-vous pour synchroniser votre programme et vous connecter avec des amis.", + "comingSoon": "Fonctionnalités de profil bientôt disponibles", + "signInWithApple": "Se connecter avec Apple", + "signInWithGoogle": "Se connecter avec Google" + }, + "settings": { + "title": "Paramètres", + "languages": { + "title": "Langue", + "systemDefault": "Utiliser la langue de l'appareil", + "en": "Anglais", + "es": "Espagnol", + "nl": "Néerlandais", + "de": "Allemand", + "fr": "Français", + "pt-BR": "Portugais (Brésil)", + "sv": "Suédois", + "pl": "Polonais" + }, + "account": { + "title": "Compte", + "signIn": "Se Connecter", + "signOut": "Se Déconnecter" + }, + "app": { + "title": "Application", + "theme": "Thème", + "language": "Langue", + "notifications": "Notifications" + }, + "data": { + "title": "Données", + "exportData": "Exporter les Données", + "importData": "Importer les Données", + "resetOnboarding": "Réinitialiser l'Intégration" + }, + "legal": { + "title": "Légal", + "privacyPolicy": "Politique de Confidentialité", + "termsOfService": "Conditions d'Utilisation", + "about": "À propos de ConPaws", + "openSourceLicenses": "Licences Open Source" + }, + "about": { + "title": "À propos", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Importer le Programme", + "url": "Importer depuis l'URL", + "file": "Importer depuis le Fichier", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Aperçu des Événements", + "importing": "Importation...", + "success": "Importation terminée", + "error": "Échec de l'importation" + }, + "reminders": { + "title": "Rappels", + "add": "Ajouter un Rappel", + "minutesBefore": "{{minutes}} minutes avant", + "atTime": "À l'heure de l'événement", + "none": "Aucun rappel" + } +} diff --git a/apps/native/src/locales/nl.json b/apps/native/src/locales/nl.json new file mode 100644 index 0000000..90f21dd --- /dev/null +++ b/apps/native/src/locales/nl.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Home", + "profile": "Profiel", + "settings": "Instellingen", + "back": "Terug", + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "done": "Klaar", + "loading": "Laden...", + "error": "Er ging iets mis", + "retry": "Opnieuw proberen", + "close": "Sluiten", + "next": "Volgende", + "skip": "Overslaan", + "getStarted": "Aan de slag", + "learnMore": "Meer informatie", + "version": "Versie {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navigeer, Verbind, Geniet", + "subtitle": "Jouw furry conventie metgezel", + "getStarted": "Aan de slag" + }, + "features": { + "title": "Alles wat je nodig hebt", + "subtitle": "ConPaws houdt jouw conventie-ervaring georganiseerd", + "calendar": { + "title": "Conventiekalender", + "description": "Importeer roosters van elke conventie. Bekijk panels, evenementen en activiteiten in één oogopslag." + }, + "share": { + "title": "Delen & Verbinden", + "description": "Maak je persoonlijke rooster en deel het met vrienden die naar dezelfde con gaan." + }, + "offline": { + "title": "Werkt Offline", + "description": "Al je gegevens worden lokaal opgeslagen. Geen internet nodig nadat je je rooster hebt geïmporteerd." + }, + "next": "Volgende" + }, + "getStarted": { + "title": "Sluit je aan bij ConPaws", + "subtitle": "Log in om je rooster op alle apparaten te synchroniseren", + "signInWithApple": "Inloggen met Apple", + "signInWithGoogle": "Inloggen met Google", + "skipForNow": "Nu overslaan", + "legal": "Door verder te gaan, ga je akkoord met onze Servicevoorwaarden en het Privacybeleid" + }, + "complete": { + "title": "Je bent er klaar voor!", + "subtitle": "Welkom bij ConPaws. Laten we jouw eerste conventie verkennen.", + "letsGo": "Laten we gaan" + } + }, + "home": { + "title": "Mijn Conventies", + "addConvention": "Conventie toevoegen", + "empty": { + "title": "Nog geen conventies", + "subtitle": "Voeg je eerste conventie toe om te beginnen.", + "cta": "Conventie toevoegen" + }, + "upcoming": "Aankomend", + "active": "Actief", + "past": "Verleden" + }, + "convention": { + "new": "Nieuwe Conventie", + "edit": "Conventie bewerken", + "import": "Rooster importeren", + "detail": "Conventiedetails", + "events": "Evenementen", + "noEvents": "Nog geen evenementen", + "noEventsSubtitle": "Importeer een rooster of voeg evenementen handmatig toe.", + "addEvent": "Evenement toevoegen", + "importSchedule": "Rooster importeren", + "startDate": "Begindatum", + "endDate": "Einddatum", + "name": "Conventienaam", + "namePlaceholder": "bijv. IndyFurCon 2025" + }, + "profile": { + "title": "Profiel", + "signIn": "Inloggen", + "notSignedIn": "Niet ingelogd", + "notSignedInSubtitle": "Log in om je rooster te synchroniseren en vrienden te verbinden.", + "comingSoon": "Profielfuncties komen binnenkort", + "signInWithApple": "Inloggen met Apple", + "signInWithGoogle": "Inloggen met Google" + }, + "settings": { + "title": "Instellingen", + "languages": { + "title": "Taal", + "systemDefault": "Apparaattaal gebruiken", + "en": "Engels", + "es": "Spaans", + "nl": "Nederlands", + "de": "Duits", + "fr": "Frans", + "pt-BR": "Portugees (Brazilië)", + "sv": "Zweeds", + "pl": "Pools" + }, + "account": { + "title": "Account", + "signIn": "Inloggen", + "signOut": "Uitloggen" + }, + "app": { + "title": "App", + "theme": "Thema", + "language": "Taal", + "notifications": "Meldingen" + }, + "data": { + "title": "Gegevens", + "exportData": "Gegevens exporteren", + "importData": "Gegevens importeren", + "resetOnboarding": "Onboarding resetten" + }, + "legal": { + "title": "Juridisch", + "privacyPolicy": "Privacybeleid", + "termsOfService": "Servicevoorwaarden", + "about": "Over ConPaws", + "openSourceLicenses": "Open Source-licenties" + }, + "about": { + "title": "Over", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Rooster importeren", + "url": "Importeren via URL", + "file": "Importeren via bestand", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Evenementen bekijken", + "importing": "Importeren...", + "success": "Import voltooid", + "error": "Import mislukt" + }, + "reminders": { + "title": "Herinneringen", + "add": "Herinnering toevoegen", + "minutesBefore": "{{minutes}} minuten van tevoren", + "atTime": "Op het tijdstip van het evenement", + "none": "Geen herinnering" + } +} diff --git a/apps/native/src/locales/pl.json b/apps/native/src/locales/pl.json new file mode 100644 index 0000000..960a677 --- /dev/null +++ b/apps/native/src/locales/pl.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Strona główna", + "profile": "Profil", + "settings": "Ustawienia", + "back": "Wstecz", + "save": "Zapisz", + "cancel": "Anuluj", + "delete": "Usuń", + "edit": "Edytuj", + "done": "Gotowe", + "loading": "Ładowanie...", + "error": "Coś poszło nie tak", + "retry": "Spróbuj ponownie", + "close": "Zamknij", + "next": "Dalej", + "skip": "Pomiń", + "getStarted": "Zacznij", + "learnMore": "Dowiedz się więcej", + "version": "Wersja {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Nawiguj, Łącz się, Ciesz się", + "subtitle": "Twój furry towarzysz konwentów", + "getStarted": "Zacznij" + }, + "features": { + "title": "Wszystko, czego potrzebujesz", + "subtitle": "ConPaws utrzymuje Twoje doświadczenie z konwentu w porządku", + "calendar": { + "title": "Kalendarz Konwentów", + "description": "Importuj harmonogramy z dowolnego konwentu. Przeglądaj panele, wydarzenia i aktywności na pierwszy rzut oka." + }, + "share": { + "title": "Udostępnij & Połącz się", + "description": "Utwórz swój osobisty harmonogram i udostępnij go przyjaciołom uczestniczącym w tym samym konie." + }, + "offline": { + "title": "Działa Offline", + "description": "Wszystkie Twoje dane są przechowywane lokalnie. Internet nie jest wymagany po zaimportowaniu harmonogramu." + }, + "next": "Dalej" + }, + "getStarted": { + "title": "Dołącz do ConPaws", + "subtitle": "Zaloguj się, aby synchronizować harmonogram między urządzeniami", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "skipForNow": "Pomiń na razie", + "legal": "Kontynuując, akceptujesz nasze Warunki korzystania z usługi i Politykę prywatności" + }, + "complete": { + "title": "Wszystko gotowe!", + "subtitle": "Witaj w ConPaws. Poznajmy Twój pierwszy konwent.", + "letsGo": "Zaczynamy" + } + }, + "home": { + "title": "Moje Konwenty", + "addConvention": "Dodaj Konwent", + "empty": { + "title": "Brak konwentów", + "subtitle": "Dodaj swój pierwszy konwent, aby zacząć.", + "cta": "Dodaj Konwent" + }, + "upcoming": "Nadchodzące", + "active": "Aktywne", + "past": "Minione" + }, + "convention": { + "new": "Nowy Konwent", + "edit": "Edytuj Konwent", + "import": "Importuj Harmonogram", + "detail": "Szczegóły Konwentu", + "events": "Wydarzenia", + "noEvents": "Brak wydarzeń", + "noEventsSubtitle": "Zaimportuj harmonogram lub dodaj wydarzenia ręcznie.", + "addEvent": "Dodaj Wydarzenie", + "importSchedule": "Importuj Harmonogram", + "startDate": "Data Rozpoczęcia", + "endDate": "Data Zakończenia", + "name": "Nazwa Konwentu", + "namePlaceholder": "np. IndyFurCon 2025" + }, + "profile": { + "title": "Profil", + "signIn": "Zaloguj się", + "notSignedIn": "Niezalogowany", + "notSignedInSubtitle": "Zaloguj się, aby synchronizować harmonogram i łączyć się z przyjaciółmi.", + "comingSoon": "Funkcje profilu wkrótce", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google" + }, + "settings": { + "title": "Ustawienia", + "languages": { + "title": "Język", + "systemDefault": "Użyj języka urządzenia", + "en": "Angielski", + "es": "Hiszpański", + "nl": "Niderlandzki", + "de": "Niemiecki", + "fr": "Francuski", + "pt-BR": "Portugalski (Brazylia)", + "sv": "Szwedzki", + "pl": "Polski" + }, + "account": { + "title": "Konto", + "signIn": "Zaloguj się", + "signOut": "Wyloguj się" + }, + "app": { + "title": "Aplikacja", + "theme": "Motyw", + "language": "Język", + "notifications": "Powiadomienia" + }, + "data": { + "title": "Dane", + "exportData": "Eksportuj Dane", + "importData": "Importuj Dane", + "resetOnboarding": "Zresetuj Wdrożenie" + }, + "legal": { + "title": "Prawne", + "privacyPolicy": "Polityka Prywatności", + "termsOfService": "Warunki Korzystania", + "about": "O ConPaws", + "openSourceLicenses": "Licencje Open Source" + }, + "about": { + "title": "O aplikacji", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Importuj Harmonogram", + "url": "Importuj z URL", + "file": "Importuj z Pliku", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Podgląd Wydarzeń", + "importing": "Importowanie...", + "success": "Import zakończony", + "error": "Import nie powiódł się" + }, + "reminders": { + "title": "Przypomnienia", + "add": "Dodaj Przypomnienie", + "minutesBefore": "{{minutes}} minut wcześniej", + "atTime": "O czasie wydarzenia", + "none": "Brak przypomnienia" + } +} diff --git a/apps/native/src/locales/pt-BR.json b/apps/native/src/locales/pt-BR.json new file mode 100644 index 0000000..6bb8b39 --- /dev/null +++ b/apps/native/src/locales/pt-BR.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Início", + "profile": "Perfil", + "settings": "Configurações", + "back": "Voltar", + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Excluir", + "edit": "Editar", + "done": "Concluído", + "loading": "Carregando...", + "error": "Algo deu errado", + "retry": "Tentar novamente", + "close": "Fechar", + "next": "Próximo", + "skip": "Pular", + "getStarted": "Começar", + "learnMore": "Saiba mais", + "version": "Versão {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navegue, Conecte, Aproveite", + "subtitle": "Seu companheiro de convenções furry", + "getStarted": "Começar" + }, + "features": { + "title": "Tudo que você precisa", + "subtitle": "O ConPaws mantém sua experiência na convenção organizada", + "calendar": { + "title": "Calendário de Convenções", + "description": "Importe programações de qualquer convenção. Veja painéis, eventos e atividades de relance." + }, + "share": { + "title": "Compartilhe & Conecte", + "description": "Crie sua programação pessoal e compartilhe com amigos que vão à mesma con." + }, + "offline": { + "title": "Funciona Offline", + "description": "Todos os seus dados são armazenados localmente. Sem internet necessária após importar sua programação." + }, + "next": "Próximo" + }, + "getStarted": { + "title": "Junte-se ao ConPaws", + "subtitle": "Faça login para sincronizar sua programação em todos os dispositivos", + "signInWithApple": "Entrar com Apple", + "signInWithGoogle": "Entrar com Google", + "skipForNow": "Pular por agora", + "legal": "Ao continuar, você concorda com nossos Termos de Serviço e Política de Privacidade" + }, + "complete": { + "title": "Tudo pronto!", + "subtitle": "Bem-vindo ao ConPaws. Vamos explorar sua primeira convenção.", + "letsGo": "Vamos lá" + } + }, + "home": { + "title": "Minhas Convenções", + "addConvention": "Adicionar Convenção", + "empty": { + "title": "Nenhuma convenção ainda", + "subtitle": "Adicione sua primeira convenção para começar.", + "cta": "Adicionar Convenção" + }, + "upcoming": "Próximas", + "active": "Ativas", + "past": "Passadas" + }, + "convention": { + "new": "Nova Convenção", + "edit": "Editar Convenção", + "import": "Importar Programação", + "detail": "Detalhes da Convenção", + "events": "Eventos", + "noEvents": "Nenhum evento ainda", + "noEventsSubtitle": "Importe uma programação ou adicione eventos manualmente.", + "addEvent": "Adicionar Evento", + "importSchedule": "Importar Programação", + "startDate": "Data de Início", + "endDate": "Data de Término", + "name": "Nome da Convenção", + "namePlaceholder": "ex. IndyFurCon 2025" + }, + "profile": { + "title": "Perfil", + "signIn": "Entrar", + "notSignedIn": "Não conectado", + "notSignedInSubtitle": "Faça login para sincronizar sua programação e conectar com amigos.", + "comingSoon": "Recursos de perfil em breve", + "signInWithApple": "Entrar com Apple", + "signInWithGoogle": "Entrar com Google" + }, + "settings": { + "title": "Configurações", + "languages": { + "title": "Idioma", + "systemDefault": "Usar idioma do dispositivo", + "en": "Inglês", + "es": "Espanhol", + "nl": "Holandês", + "de": "Alemão", + "fr": "Francês", + "pt-BR": "Português (Brasil)", + "sv": "Sueco", + "pl": "Polonês" + }, + "account": { + "title": "Conta", + "signIn": "Entrar", + "signOut": "Sair" + }, + "app": { + "title": "Aplicativo", + "theme": "Tema", + "language": "Idioma", + "notifications": "Notificações" + }, + "data": { + "title": "Dados", + "exportData": "Exportar Dados", + "importData": "Importar Dados", + "resetOnboarding": "Redefinir Integração" + }, + "legal": { + "title": "Legal", + "privacyPolicy": "Política de Privacidade", + "termsOfService": "Termos de Serviço", + "about": "Sobre o ConPaws", + "openSourceLicenses": "Licenças Open Source" + }, + "about": { + "title": "Sobre", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Importar Programação", + "url": "Importar da URL", + "file": "Importar do Arquivo", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Prévia dos Eventos", + "importing": "Importando...", + "success": "Importação concluída", + "error": "Falha na importação" + }, + "reminders": { + "title": "Lembretes", + "add": "Adicionar Lembrete", + "minutesBefore": "{{minutes}} minutos antes", + "atTime": "No horário do evento", + "none": "Sem lembrete" + } +} diff --git a/apps/native/src/locales/sv.json b/apps/native/src/locales/sv.json new file mode 100644 index 0000000..83e54b0 --- /dev/null +++ b/apps/native/src/locales/sv.json @@ -0,0 +1,155 @@ +{ + "common": { + "home": "Hem", + "profile": "Profil", + "settings": "Inställningar", + "back": "Tillbaka", + "save": "Spara", + "cancel": "Avbryt", + "delete": "Ta bort", + "edit": "Redigera", + "done": "Klar", + "loading": "Laddar...", + "error": "Något gick fel", + "retry": "Försök igen", + "close": "Stäng", + "next": "Nästa", + "skip": "Hoppa över", + "getStarted": "Kom igång", + "learnMore": "Läs mer", + "version": "Version {{version}}" + }, + "onboarding": { + "welcome": { + "tagline": "Navigera, Anslut, Njut", + "subtitle": "Din furry-konvents följeslagare", + "getStarted": "Kom igång" + }, + "features": { + "title": "Allt du behöver", + "subtitle": "ConPaws håller din konventionsupplevelse organiserad", + "calendar": { + "title": "Konventskalender", + "description": "Importera scheman från vilket konvent som helst. Bläddra bland paneler, evenemang och aktiviteter på ett ögonblick." + }, + "share": { + "title": "Dela & Anslut", + "description": "Bygg ditt personliga schema och dela det med vänner som deltar i samma con." + }, + "offline": { + "title": "Fungerar Offline", + "description": "All din data lagras lokalt. Ingen internet krävs när du har importerat ditt schema." + }, + "next": "Nästa" + }, + "getStarted": { + "title": "Gå med i ConPaws", + "subtitle": "Logga in för att synkronisera ditt schema mellan enheter", + "signInWithApple": "Logga in med Apple", + "signInWithGoogle": "Logga in med Google", + "skipForNow": "Hoppa över för nu", + "legal": "Genom att fortsätta godkänner du våra Användarvillkor och Integritetspolicy" + }, + "complete": { + "title": "Du är redo!", + "subtitle": "Välkommen till ConPaws. Låt oss utforska ditt första konvent.", + "letsGo": "Kör igång" + } + }, + "home": { + "title": "Mina Konvent", + "addConvention": "Lägg till Konvent", + "empty": { + "title": "Inga konvent ännu", + "subtitle": "Lägg till ditt första konvent för att komma igång.", + "cta": "Lägg till Konvent" + }, + "upcoming": "Kommande", + "active": "Aktiva", + "past": "Avslutade" + }, + "convention": { + "new": "Nytt Konvent", + "edit": "Redigera Konvent", + "import": "Importera Schema", + "detail": "Konventdetaljer", + "events": "Evenemang", + "noEvents": "Inga evenemang ännu", + "noEventsSubtitle": "Importera ett schema eller lägg till evenemang manuellt.", + "addEvent": "Lägg till Evenemang", + "importSchedule": "Importera Schema", + "startDate": "Startdatum", + "endDate": "Slutdatum", + "name": "Konventnamn", + "namePlaceholder": "t.ex. IndyFurCon 2025" + }, + "profile": { + "title": "Profil", + "signIn": "Logga In", + "notSignedIn": "Inte inloggad", + "notSignedInSubtitle": "Logga in för att synkronisera ditt schema och ansluta med vänner.", + "comingSoon": "Profilfunktioner kommer snart", + "signInWithApple": "Logga in med Apple", + "signInWithGoogle": "Logga in med Google" + }, + "settings": { + "title": "Inställningar", + "languages": { + "title": "Språk", + "systemDefault": "Använd enhetsspråk", + "en": "Engelska", + "es": "Spanska", + "nl": "Nederländska", + "de": "Tyska", + "fr": "Franska", + "pt-BR": "Portugisiska (Brasilien)", + "sv": "Svenska", + "pl": "Polska" + }, + "account": { + "title": "Konto", + "signIn": "Logga In", + "signOut": "Logga Ut" + }, + "app": { + "title": "App", + "theme": "Tema", + "language": "Språk", + "notifications": "Aviseringar" + }, + "data": { + "title": "Data", + "exportData": "Exportera Data", + "importData": "Importera Data", + "resetOnboarding": "Återställ Introduktion" + }, + "legal": { + "title": "Juridiskt", + "privacyPolicy": "Integritetspolicy", + "termsOfService": "Användarvillkor", + "about": "Om ConPaws", + "openSourceLicenses": "Öppen källkodslicenser" + }, + "about": { + "title": "Om", + "contact": "hello@conpaws.com" + } + }, + "import": { + "title": "Importera Schema", + "url": "Importera från URL", + "file": "Importera från Fil", + "urlPlaceholder": "https://yourcon.sched.com", + "preview": "Förhandsgranska Evenemang", + "importing": "Importerar...", + "success": "Import klar", + "error": "Importen misslyckades" + }, + "reminders": { + "title": "Påminnelser", + "add": "Lägg till Påminnelse", + "minutesBefore": "{{minutes}} minuter innan", + "atTime": "Vid evenemangstid", + "none": "Ingen påminnelse" + } +} diff --git a/apps/native/src/services/data-export.ts b/apps/native/src/services/data-export.ts new file mode 100644 index 0000000..66ef32a --- /dev/null +++ b/apps/native/src/services/data-export.ts @@ -0,0 +1,72 @@ +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import * as Haptics from 'expo-haptics'; +import { useMutation } from '@tanstack/react-query'; +import * as conventionsRepo from '@/db/repositories/conventions'; +import * as eventsRepo from '@/db/repositories/events'; +import type { Convention, ConventionEvent } from '@/db/schema'; + +export interface ExportPayload { + version: 1; + exportedAt: string; + app: 'ConPaws'; + data: { + conventions: Convention[]; + events: ConventionEvent[]; + }; +} + +export async function exportAllData(): Promise { + const conventions = await conventionsRepo.getAll(); + + const allEvents: ConventionEvent[] = []; + for (const conv of conventions) { + const events = await eventsRepo.getByConventionId(conv.id); + allEvents.push(...events); + } + + return { + version: 1, + exportedAt: new Date().toISOString(), + app: 'ConPaws', + data: { + conventions, + events: allEvents, + }, + }; +} + +export async function triggerExport(): Promise { + const payload = await exportAllData(); + const json = JSON.stringify(payload, null, 2); + + const filename = `conpaws-export-${new Date().toISOString().slice(0, 10)}.json`; + const fileUri = `${FileSystem.cacheDirectory}${filename}`; + + await FileSystem.writeAsStringAsync(fileUri, json, { + encoding: FileSystem.EncodingType.UTF8, + }); + + const canShare = await Sharing.isAvailableAsync(); + if (!canShare) { + throw new Error('Sharing is not available on this device'); + } + + await Sharing.shareAsync(fileUri, { + mimeType: 'application/json', + dialogTitle: 'Export ConPaws Data', + }); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); +} + +export function useExportData() { + const mutation = useMutation({ + mutationFn: triggerExport, + }); + + return { + exportData: mutation.mutate, + isLoading: mutation.isPending, + }; +} diff --git a/apps/native/src/services/data-import.ts b/apps/native/src/services/data-import.ts new file mode 100644 index 0000000..e4d33b0 --- /dev/null +++ b/apps/native/src/services/data-import.ts @@ -0,0 +1,176 @@ +import * as DocumentPicker from 'expo-document-picker'; +import * as FileSystem from 'expo-file-system'; +import { Alert } from 'react-native'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import * as conventionsRepo from '@/db/repositories/conventions'; +import * as eventsRepo from '@/db/repositories/events'; +import type { Convention, ConventionEvent } from '@/db/schema'; +import type { ExportPayload } from './data-export'; + +export interface ImportResult { + conventionsAdded: number; + eventsAdded: number; + skipped: number; +} + +export function validateImportFile(payload: unknown): ExportPayload { + if (typeof payload !== 'object' || payload === null) { + throw new Error('Invalid file: not a JSON object'); + } + + const obj = payload as Record; + + if (obj.version !== 1) { + throw new Error('Invalid file: unsupported version'); + } + if (obj.app !== 'ConPaws') { + throw new Error('Invalid file: not a ConPaws export'); + } + if (typeof obj.data !== 'object' || obj.data === null) { + throw new Error('Invalid file: missing data'); + } + + const data = obj.data as Record; + if (!Array.isArray(data.conventions) || !Array.isArray(data.events)) { + throw new Error('Invalid file: missing conventions or events array'); + } + + return payload as ExportPayload; +} + +export async function importData(payload: ExportPayload): Promise { + const existingConventions = await conventionsRepo.getAll(); + const existingIds = new Set(existingConventions.map((c) => c.id)); + + let conventionsAdded = 0; + let eventsAdded = 0; + let skipped = 0; + + const now = new Date().toISOString(); + + // Import conventions (skip existing IDs) + for (const conv of payload.data.conventions) { + if (existingIds.has(conv.id)) { + skipped++; + continue; + } + await conventionsRepo.create({ + name: conv.name, + startDate: conv.startDate, + endDate: conv.endDate, + icalUrl: conv.icalUrl, + status: conv.status, + }); + conventionsAdded++; + } + + // Import events (skip existing IDs) + const existingAllConventions = await conventionsRepo.getAll(); + const allEventIds = new Set(); + for (const conv of existingAllConventions) { + const events = await eventsRepo.getByConventionId(conv.id); + events.forEach((e) => allEventIds.add(e.id)); + } + + for (const event of payload.data.events) { + if (allEventIds.has(event.id)) { + skipped++; + continue; + } + // Only import if convention exists + const convExists = existingAllConventions.some((c) => c.id === event.conventionId); + if (!convExists) { + skipped++; + continue; + } + await eventsRepo.batchInsert([ + { + conventionId: event.conventionId, + title: event.title, + description: event.description, + startTime: event.startTime, + endTime: event.endTime, + location: event.location, + room: event.room, + category: event.category, + type: event.type, + isInSchedule: event.isInSchedule, + reminderMinutes: event.reminderMinutes, + sourceUid: event.sourceUid, + sourceUrl: event.sourceUrl, + isAgeRestricted: event.isAgeRestricted, + contentWarning: event.contentWarning, + }, + ]); + eventsAdded++; + } + + return { conventionsAdded, eventsAdded, skipped }; +} + +export function useImportData() { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async () => { + const result = await DocumentPicker.getDocumentAsync({ + type: ['application/json', 'text/plain', '*/*'], + copyToCacheDirectory: true, + }); + + if (result.canceled) return null; + + const file = result.assets[0]; + const content = await FileSystem.readAsStringAsync(file.uri); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error('Invalid file: could not parse JSON'); + } + + const payload = validateImportFile(parsed); + + const convCount = payload.data.conventions.length; + const eventCount = payload.data.events.length; + + return new Promise((resolve, reject) => { + Alert.alert( + 'Import Data', + `Import ${convCount} convention${convCount !== 1 ? 's' : ''} and ${eventCount} event${eventCount !== 1 ? 's' : ''}?`, + [ + { text: 'Cancel', style: 'cancel', onPress: () => resolve({ conventionsAdded: 0, eventsAdded: 0, skipped: 0 }) }, + { + text: 'Import', + onPress: async () => { + try { + const importResult = await importData(payload); + resolve(importResult); + } catch (err) { + reject(err); + } + }, + }, + ], + ); + }); + }, + onSuccess: (result) => { + if (!result) return; + queryClient.invalidateQueries({ queryKey: ['conventions'] }); + Alert.alert( + 'Import Complete', + `${result.conventionsAdded} conventions and ${result.eventsAdded} events imported. ${result.skipped} skipped.`, + ); + }, + onError: (error) => { + Alert.alert('Import Failed', (error as Error).message ?? 'Something went wrong.'); + }, + }); + + return { + importData: mutation.mutate, + isLoading: mutation.isPending, + }; +} diff --git a/apps/native/src/services/notifications.ts b/apps/native/src/services/notifications.ts new file mode 100644 index 0000000..a0ba8c7 --- /dev/null +++ b/apps/native/src/services/notifications.ts @@ -0,0 +1,87 @@ +import * as ExpoNotifications from 'expo-notifications'; + +export type PermissionStatus = 'granted' | 'denied' | 'undetermined'; + +export function setupNotificationHandler(): void { + ExpoNotifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), + }); +} + +export async function requestNotificationPermission(): Promise { + const { status: existing } = await ExpoNotifications.getPermissionsAsync(); + if (existing === 'granted') return 'granted'; + + const { status } = await ExpoNotifications.requestPermissionsAsync(); + return status as PermissionStatus; +} + +export async function getNotificationPermissionStatus(): Promise { + const { status } = await ExpoNotifications.getPermissionsAsync(); + return status as PermissionStatus; +} + +interface EventForReminder { + id: string; + title: string; + startTime: string; // ISO string + room: string | null; +} + +export async function scheduleEventReminder( + event: EventForReminder, + minutesBefore: number, +): Promise { + const startMs = new Date(event.startTime).getTime(); + const triggerMs = startMs - minutesBefore * 60 * 1000; + + if (triggerMs <= Date.now()) { + return null; // In the past + } + + const notificationId = `reminder-${event.id}`; + + // Cancel any existing reminder for this event + try { + await ExpoNotifications.cancelScheduledNotificationAsync(notificationId); + } catch { + // May not exist + } + + const body = event.room + ? `Starting in ${minutesBefore} min · ${event.room}` + : `Starting in ${minutesBefore} min`; + + await ExpoNotifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: event.title, + body, + sound: true, + }, + trigger: { + type: ExpoNotifications.SchedulableTriggerInputTypes.DATE, + date: new Date(triggerMs), + }, + }); + + return notificationId; +} + +export async function cancelEventReminder(eventId: string): Promise { + try { + await ExpoNotifications.cancelScheduledNotificationAsync(`reminder-${eventId}`); + } catch { + // Ignore + } +} + +export async function cancelConventionReminders(eventIds: string[]): Promise { + await Promise.all(eventIds.map((id) => cancelEventReminder(id))); +} diff --git a/apps/native/tsconfig.json b/apps/native/tsconfig.json index ce27fee..98475c0 100644 --- a/apps/native/tsconfig.json +++ b/apps/native/tsconfig.json @@ -3,8 +3,8 @@ "compilerOptions": { "strict": true, "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"] } diff --git a/apps/native/vitest.config.ts b/apps/native/vitest.config.ts new file mode 100644 index 0000000..c5975ac --- /dev/null +++ b/apps/native/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..5fa9121 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@conpaws/server", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.12" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250622.0", + "typescript": "^5", + "wrangler": "^4.23.0" + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..09ab6a2 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,87 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; + +type Env = { + BREVO_API_KEY: string; + BREVO_LIST_ID: string; +}; + +const app = new Hono<{ Bindings: Env }>(); + +app.use( + '*', + cors({ + origin: ['https://conpaws.com', 'http://localhost:3000', 'http://localhost:3001'], + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type'], + }), +); + +app.get('/health', (c) => { + return c.json({ status: 'ok' }); +}); + +app.post('/subscribe', async (c) => { + let body: { name?: string; email?: string; honeypot?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON' }, 400); + } + + const { name, email, honeypot } = body; + + // Honeypot — silent success for bots + if (honeypot) { + return c.json({ success: true }); + } + + // Validation + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return c.json({ error: 'Name is required' }, 400); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!email || typeof email !== 'string' || !emailRegex.test(email)) { + return c.json({ error: 'Valid email is required' }, 400); + } + + // Add to Brevo + try { + const res = await fetch('https://api.brevo.com/v3/contacts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-key': c.env.BREVO_API_KEY, + }, + body: JSON.stringify({ + email: email.trim().toLowerCase(), + attributes: { FIRSTNAME: name.trim() }, + listIds: [parseInt(c.env.BREVO_LIST_ID, 10)], + updateEnabled: true, + }), + }); + + if (res.status === 204 || res.status === 201 || res.status === 200) { + return c.json({ success: true }); + } + + const data = await res.json() as { code?: string; message?: string }; + + // Duplicate contact — treat as success + if (data.code === 'duplicate_parameter') { + return c.json({ success: true }); + } + + // Bad email format reported by Brevo + if (res.status === 400) { + return c.json({ error: 'Invalid email address' }, 400); + } + + return c.json({ error: 'Failed to subscribe. Please try again.' }, 500); + } catch { + return c.json({ error: 'Network error. Please try again.' }, 500); + } +}); + +export default app; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..aecb98e --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "types": ["@cloudflare/workers-types"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/server/wrangler.toml b/apps/server/wrangler.toml new file mode 100644 index 0000000..b4837e6 --- /dev/null +++ b/apps/server/wrangler.toml @@ -0,0 +1,9 @@ +name = "conpaws-api" +main = "src/index.ts" +compatibility_date = "2025-06-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +BREVO_LIST_ID = "1" + +# BREVO_API_KEY is a secret — set with: wrangler secret put BREVO_API_KEY diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..9949560 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=https://api.conpaws.com diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ca84071..4597eb8 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,9 +1,11 @@ -import "@conpaws/env/web"; -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - typedRoutes: true, - reactCompiler: true, + output: 'export', + trailingSlash: true, + images: { + unoptimized: true, + }, }; export default nextConfig; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..a250d74 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; + +:root { + --background: #091533; + --foreground: #f8fafc; + --accent: #0FACED; + --muted: #1e3a5f; + --border: #1e3a5f; +} + +* { + box-sizing: border-box; +} + +body { + background-color: var(--background); + color: var(--foreground); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 38340e3..3a2f11f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,40 +1,28 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; - -import "../index.css"; -import Header from "@/components/header"; -import Providers from "@/components/providers"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "conpaws", - description: "conpaws", + title: 'ConPaws — Your Furry Convention Companion', + description: + 'Navigate conventions, import schedules, build your personal agenda, and never miss a panel. Coming to iOS & Android.', + openGraph: { + title: 'ConPaws — Your Furry Convention Companion', + description: 'Navigate conventions, import schedules, build your personal agenda.', + url: 'https://conpaws.com', + siteName: 'ConPaws', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'ConPaws', + description: 'Your furry convention companion. Coming soon.', + }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - -
-
- {children} -
-
- + + {children} ); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 7698efd..7d01c08 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,30 +1,193 @@ -"use client"; - -const TITLE_TEXT = ` - ██████╗ ███████╗████████╗████████╗███████╗██████╗ - ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ - ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ - ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ - ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ - ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ - - ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ - ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ - ██║ ███████╗ ██║ ███████║██║ █████╔╝ - ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ - ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ - ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ - `; - -export default function Home() { +'use client'; + +import { useState } from 'react'; +import { Calendar, Share2, WifiOff, Github } from 'lucide-react'; + +const FEATURES = [ + { + icon: Calendar, + title: 'Convention Calendar', + description: + 'Import schedules from any convention. Browse panels, events, and activities at a glance.', + }, + { + icon: Share2, + title: 'Share & Connect', + description: + 'Build your personal schedule and share it with friends attending the same con.', + }, + { + icon: WifiOff, + title: 'Works Offline', + description: + 'All your data is stored locally. No internet required once you\u2019ve imported your schedule.', + }, +]; + +interface FormState { + status: 'idle' | 'loading' | 'success' | 'error'; + message: string; +} + +export default function HomePage() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [honeypot, setHoneypot] = useState(''); + const [form, setForm] = useState({ status: 'idle', message: '' }); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (form.status === 'loading') return; + + setForm({ status: 'loading', message: '' }); + + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.conpaws.com'; + const res = await fetch(`${apiUrl}/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, honeypot }), + }); + + const data = await res.json() as { success?: boolean; error?: string }; + + if (data.success) { + setForm({ status: 'success', message: 'You\u2019re on the list! We\u2019ll reach out when ConPaws launches.' }); + setName(''); + setEmail(''); + } else { + setForm({ status: 'error', message: data.error ?? 'Something went wrong. Please try again.' }); + } + } catch { + setForm({ status: 'error', message: 'Network error. Please try again.' }); + } + } + return ( -
-
{TITLE_TEXT}
-
-
-

API Status

-
-
-
+
+ {/* Hero */} +
+
+ CP +
+
+

+ ConPaws +

+

+ Navigate, Connect, Enjoy — Your furry convention companion +

+
+
+ + Coming to iOS & Android +
+
+ + {/* Features */} +
+
+ {FEATURES.map((feature) => ( +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+ + {/* Beta signup */} +
+
+
+

Get Early Access

+

+ Be the first to know when ConPaws launches. No spam, just the launch announcement. +

+
+ + {form.status === 'success' ? ( +
+

{form.message}

+
+ ) : ( +
+ {/* Honeypot — hidden from humans */} + setHoneypot(e.target.value)} + tabIndex={-1} + autoComplete="off" + style={{ display: 'none' }} + /> + setName(e.target.value)} + required + className="bg-[#091533] border border-[#1e3a5f] rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-[#0FACED] transition-colors" + /> + setEmail(e.target.value)} + required + className="bg-[#091533] border border-[#1e3a5f] rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-[#0FACED] transition-colors" + /> + {form.status === 'error' && ( +

{form.message}

+ )} + +
+ )} +
+
+ + {/* Footer */} + +
); } diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx new file mode 100644 index 0000000..0fd8081 --- /dev/null +++ b/apps/web/src/app/privacy/page.tsx @@ -0,0 +1,78 @@ +import Link from 'next/link'; + +export default function PrivacyPage() { + return ( +
+ + ← Back to ConPaws + +

Privacy Policy

+

Last updated: March 17, 2026

+ +
+
+

1. Data We Collect

+

+ ConPaws is a local-first app. Convention schedules, events, and your personal + schedule are stored exclusively on your device using SQLite. We do not transmit + this data to any server. +

+

+ If you sign up for the beta waitlist on our website, we collect your name and + email address to send you a launch notification. We use Brevo to manage this list. +

+
+ +
+

2. Data We Do NOT Collect

+
    +
  • Your convention attendance history
  • +
  • Your personal schedule or saved events
  • +
  • Device identifiers or location data
  • +
  • Analytics or crash data (Phase 1)
  • +
+
+ +
+

3. Third-Party Services

+

+ RevenueCat — used for in-app subscription + management (ConPaws+ features). RevenueCat may collect purchase receipts and + subscription status. See their privacy policy at revenuecat.com. +

+

+ Supabase — used for optional account sync + (Phase 2+). If you create an account, your profile and schedule are synced to our + self-hosted Supabase instance. +

+
+ +
+

4. Push Notifications

+

+ If you enable event reminders, notifications are scheduled locally on your device + via expo-notifications. No notification data is sent to our servers. +

+
+ +
+

5. Children's Privacy

+

+ ConPaws is not directed to children under 13. We do not knowingly collect personal + information from children. +

+
+ +
+

6. Contact Us

+

+ Questions? Email us at{' '} + + hello@conpaws.com + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx new file mode 100644 index 0000000..bb50ceb --- /dev/null +++ b/apps/web/src/app/terms/page.tsx @@ -0,0 +1,88 @@ +import Link from 'next/link'; + +export default function TermsPage() { + return ( +
+ + ← Back to ConPaws + +

Terms of Service

+

Last updated: March 17, 2026

+ +
+
+

1. Acceptance of Terms

+

+ By downloading or using ConPaws, you agree to these Terms of Service. If you do + not agree, please do not use the app. +

+
+ +
+

2. Use of the App

+

+ ConPaws is provided for personal, non-commercial use. You may use it to import + and manage convention schedules for yourself. You may not reverse engineer, + redistribute, or use the app for commercial purposes without written permission. +

+
+ +
+

3. iCal Content

+

+ Convention schedule data imported into ConPaws belongs to the respective convention + organizers. ConPaws does not claim ownership of imported schedule data. We are not + responsible for the accuracy or availability of third-party schedule data. +

+
+ +
+

4. ConPaws+ Subscriptions

+

+ Premium features ("ConPaws+") are available via in-app subscription through the + Apple App Store or Google Play Store. Subscription terms, pricing, and refund + policies are governed by Apple/Google's standard terms. Cancel anytime via your + device's subscription settings. +

+
+ +
+

5. Disclaimer of Warranties

+

+ ConPaws is provided "as is" without warranties of any kind. We do not guarantee + that the app will be error-free or available at all times. Convention schedule + data is sourced from third parties and may be incomplete or inaccurate. +

+
+ +
+

6. Limitation of Liability

+

+ To the maximum extent permitted by law, ConPaws and its developers shall not be + liable for any indirect, incidental, or consequential damages arising from your + use of the app. +

+
+ +
+

7. Changes to Terms

+

+ We may update these terms from time to time. Continued use of the app after + changes constitutes acceptance of the new terms. Material changes will be + communicated via app update notes. +

+
+ +
+

8. Contact

+

+ Questions?{' '} + + hello@conpaws.com + +

+
+
+
+ ); +} diff --git a/bun.lock b/bun.lock index c6d6eb2..59c5f97 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,9 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.39.3", "expo": "55.0.4", + "expo-constants": "^55.0.8", "expo-document-picker": "~55.0.8", + "expo-file-system": "^55.0.11", "expo-haptics": "~55.0.8", "expo-linking": "~55.0.7", "expo-localization": "~55.0.8", @@ -41,6 +43,7 @@ "react": "19.0.0", "react-i18next": "^16.5.4", "react-native": "0.83.0", + "react-native-css": "^3.0.5", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", @@ -49,8 +52,10 @@ "tailwindcss": "^4.1.18", }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", "@types/react": "^19.2.14", "drizzle-kit": "^0.30.6", + "postcss": "^8.5.8", "typescript": "~5.9.3", "vitest": "^3.2.4", }, @@ -1180,11 +1185,11 @@ "expo-asset": ["expo-asset@55.0.8", "", { "dependencies": { "@expo/image-utils": "^0.8.12", "expo-constants": "~55.0.7" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-yEz2svDX67R0yiW2skx6dJmcE0q7sj9ECpGMcxBExMCbctc+nMoZCnjUuhzPl5vhClUsO5HFFXS5vIGmf1bgHQ=="], - "expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="], + "expo-constants": ["expo-constants@55.0.8", "", { "dependencies": { "@expo/config": "~55.0.9", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-fhB+8EePHyHu2fVHFRObKV7QL4RCQc6OfJyNn34f6/KEoA3e0q/iCL24IUW2RoIYq+sdNHl09MjDU0Hhh1Ec4A=="], "expo-document-picker": ["expo-document-picker@55.0.8", "", { "peerDependencies": { "expo": "*" } }, "sha512-p6rYEQ1/h3UqGl3+hzTjv51fsNxoOVfMGSYjHX2/e3cvcy02MWWE+bpj4QEGo9MBwU4RyyIbuv/SCxGtAtG+eA=="], - "expo-file-system": ["expo-file-system@55.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw=="], + "expo-file-system": ["expo-file-system@55.0.11", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-KMUd6OY375J9WD79ZvjvCDZMveT7YfgiGWdi58/gfuTBsr14TRuoPk8RRQHAtc4UquzWViKcHwna9aPY7/XPpw=="], "expo-font": ["expo-font@55.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg=="], @@ -2258,8 +2263,20 @@ "execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "expo/expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="], + + "expo/expo-file-system": ["expo-file-system@55.0.10", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw=="], + + "expo-asset/expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="], + + "expo-constants/@expo/config": ["@expo/config@55.0.9", "", { "dependencies": { "@expo/config-plugins": "~55.0.6", "@expo/config-types": "^55.0.5", "@expo/json-file": "^10.0.12", "@expo/require-utils": "^55.0.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-uYPwTnBtp7aSGhNjvdhqUfi8SodvDlIqKzWq94WcWaFBr/RzcJ/pa0TZOy2E6YgjvrcZOcSj3xRQYwWoo+9jag=="], + + "expo-linking/expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="], + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "expo-notifications/expo-constants": ["expo-constants@55.0.7", "", { "dependencies": { "@expo/config": "~55.0.8", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ=="], + "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2570,6 +2587,12 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "expo-constants/@expo/config/@expo/require-utils": ["@expo/require-utils@55.0.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0" }, "optionalPeers": ["typescript"] }, "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw=="], + + "expo-constants/@expo/config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "expo-constants/@expo/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], From dbde4c4d46bf0da5d8dbe872cf309c91e3f66166 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:04:17 -0500 Subject: [PATCH 2/5] ci: add workflow to auto-update license year annually Runs on Jan 1 at 06:00 UTC (midnight CST) and opens a PR to bump the copyright year in LICENSE. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/update-license-year.yml | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/update-license-year.yml diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml new file mode 100644 index 0000000..ea08fba --- /dev/null +++ b/.github/workflows/update-license-year.yml @@ -0,0 +1,38 @@ +name: Update License Year + +on: + schedule: + - cron: '0 6 1 1 *' + workflow_dispatch: + +jobs: + update-license-year: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get current year + id: year + run: echo "year=$(date +%Y)" >> "$GITHUB_OUTPUT" + + - name: Update year in LICENSE + run: sed -i "s/Copyright (c) [0-9]\{4\}/Copyright (c) ${{ steps.year.outputs.year }}/" LICENSE + + - name: Check for changes + id: diff + run: | + if git diff --quiet LICENSE; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create Pull Request + if: steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/update-license-year + commit-message: 'chore: update license year to ${{ steps.year.outputs.year }}' + title: 'chore: update license year to ${{ steps.year.outputs.year }}' + body: Automated annual update of the MIT license copyright year. + labels: chore From 7da25c9c66e6497ad229645d3e3b75146784a649 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:42:10 -0500 Subject: [PATCH 3/5] chore: pin CI actions, rewrite README, and polish app UI Pin GitHub Actions to commit SHAs for supply-chain safety, rewrite README to reflect the current project scope, add error handling to onboarding completion, update Badge component usage, and refine web landing/privacy pages. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/update-license-year.yml | 4 +- README.md | 111 ++++++---------------- apps/native/app/(onboarding)/complete.tsx | 9 +- apps/native/app/(tabs)/profile.tsx | 8 +- apps/native/src/services/data-import.ts | 15 ++- apps/web/.env.example | 7 +- apps/web/src/app/page.tsx | 48 +++++++++- apps/web/src/app/privacy/page.tsx | 17 ++-- 8 files changed, 113 insertions(+), 106 deletions(-) diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml index ea08fba..fa482c2 100644 --- a/.github/workflows/update-license-year.yml +++ b/.github/workflows/update-license-year.yml @@ -9,7 +9,7 @@ jobs: update-license-year: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Get current year id: year @@ -29,7 +29,7 @@ jobs: - name: Create Pull Request if: steps.diff.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: branch: chore/update-license-year commit-message: 'chore: update license year to ${{ steps.year.outputs.year }}' diff --git a/README.md b/README.md index c310251..760f3e5 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,48 @@ -# conpaws +# ConPaws -This project was created with [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack), a modern TypeScript stack that combines Next.js, and more. +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/MrDemonWolf/conpaws/blob/main/LICENSE) -## Features +ConPaws is an open source furry convention companion app built by [MrDemonWolf](https://github.com/MrDemonWolf), coming soon to the Apple App Store and Google Play Store. Feel free to fork it and adapt it for your own conventions or fandom community. -- **TypeScript** - For type safety and improved developer experience -- **Next.js** - Full-stack React framework -- **React Native** - Build mobile apps using React -- **Expo** - Tools for React Native development -- **TailwindCSS** - Utility-first CSS for rapid UI development -- **Shared UI package** - shadcn/ui primitives live in `packages/ui` -- **Turborepo** - Optimized monorepo build system +## What it does -## Getting Started +- **Local-first** — all core features work offline on iOS, Android, and Web +- **Import schedules** — pull in convention events via iCal files or Sched.com URLs +- **Build your schedule** — mark events you want to attend and get reminders +- **ConPaws+** — premium features via RevenueCat (Phase 3+) -First, install the dependencies: +## Project Structure -```bash -bun install ``` - -Then, run the development server: - -```bash -bun run dev +conpaws/ +├── apps/mobile/ # Expo React Native app (iOS, Android, Web) +├── apps/web/ # Next.js static site → conpaws.com +├── apps/server/ # Hono API → api.conpaws.com (Cloudflare Workers) +└── apps/docs/ # Fumadocs → docs.conpaws.com ``` -Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application. -Use the Expo Go app to run the mobile application. - -## UI Customization - -React web apps in this stack share shadcn/ui primitives through `packages/ui`. - -- Change design tokens and global styles in `packages/ui/src/styles/globals.css` -- Update shared primitives in `packages/ui/src/components/*` -- Adjust shadcn aliases or style config in `packages/ui/components.json` and `apps/web/components.json` - -### Add more shared components - -Run this from the project root to add more primitives to the shared UI package: +## Getting Started ```bash -bunx shadcn@latest add accordion dialog popover sheet table -c packages/ui -``` - -Import shared components like this: - -```tsx -import { Button } from "@conpaws/ui/components/button"; +bun install ``` -### Add app-specific blocks - -If you want to add app-specific blocks instead of shared primitives, run the shadcn CLI from `apps/web`. +### Mobile app (`apps/mobile`) -## Project Structure - -``` -conpaws/ -├── apps/ -│ ├── web/ # Frontend application (Next.js) -│ ├── native/ # Mobile application (React Native, Expo) -├── packages/ -│ ├── ui/ # Shared shadcn/ui components and styles +```bash +bun start # Start Expo dev server (development variant) +bun start:preview # Start Expo dev server (preview variant) +bun start:prod # Start Expo dev server (production variant) +bun android # Run on Android +bun ios # Run on iOS +bun web # Run web version +bun lint # Run ESLint +bun type-check # Run TypeScript type checking +bun test # Run tests (Vitest) +bun prebuild # Generate native projects +bun prebuild:clean # Clean and regenerate native projects ``` -## Available Scripts - -### Root (Monorepo) - -- `bun run dev` — Start all applications in development mode -- `bun run build` — Build all applications -- `bun run check-types` — Check TypeScript types across all apps -- `bun run dev:native` — Start the React Native/Expo development server -- `bun run dev:web` — Start only the web application - -### Native App (`apps/native`) - -- `bun start` — Start Expo dev server (development variant) -- `bun start:preview` — Start Expo dev server (preview variant) -- `bun start:prod` — Start Expo dev server (production variant) -- `bun android` — Run on Android -- `bun ios` — Run on iOS -- `bun web` — Run web version -- `bun run lint` — Run ESLint -- `bun run type-check` — Run TypeScript type checking -- `bun test` — Run tests (Vitest) -- `bun run prebuild` — Generate native projects -- `bun run prebuild:clean` — Clean and regenerate native projects - -### Web App (`apps/web`) +## Want to use this for your own fandom? -- `bun run dev` — Start Next.js dev server -- `bun run build` — Build for production -- `bun run start` — Start production server +Fork it. The app is built to be self-hostable — swap in your own Supabase instance, configure your RevenueCat keys, and point it at your conventions. The iCal import works with any standard `.ics` file, so it should work with most convention schedule tools out of the box. diff --git a/apps/native/app/(onboarding)/complete.tsx b/apps/native/app/(onboarding)/complete.tsx index db09b14..795ff75 100644 --- a/apps/native/app/(onboarding)/complete.tsx +++ b/apps/native/app/(onboarding)/complete.tsx @@ -8,8 +8,13 @@ export default function CompleteScreen() { const { t } = useTranslation(); async function handleLetsGo() { - await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); - router.replace('/(tabs)'); + try { + await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); + } catch { + // Storage write failed — still navigate so user isn't stuck + } finally { + router.replace('/(tabs)'); + } } return ( diff --git a/apps/native/app/(tabs)/profile.tsx b/apps/native/app/(tabs)/profile.tsx index 4bb43ae..922d656 100644 --- a/apps/native/app/(tabs)/profile.tsx +++ b/apps/native/app/(tabs)/profile.tsx @@ -27,9 +27,7 @@ export default function ProfileScreen() { {t('profile.signInWithApple')} - - Coming Soon - +
@@ -39,9 +37,7 @@ export default function ProfileScreen() { {t('profile.signInWithGoogle')} - - Coming Soon - +
diff --git a/apps/native/src/services/data-import.ts b/apps/native/src/services/data-import.ts index e4d33b0..6692f89 100644 --- a/apps/native/src/services/data-import.ts +++ b/apps/native/src/services/data-import.ts @@ -48,19 +48,24 @@ export async function importData(payload: ExportPayload): Promise const now = new Date().toISOString(); + // Track old export ID → new DB ID for newly created conventions + const idMap = new Map(); + // Import conventions (skip existing IDs) for (const conv of payload.data.conventions) { if (existingIds.has(conv.id)) { + idMap.set(conv.id, conv.id); skipped++; continue; } - await conventionsRepo.create({ + const created = await conventionsRepo.create({ name: conv.name, startDate: conv.startDate, endDate: conv.endDate, icalUrl: conv.icalUrl, status: conv.status, }); + idMap.set(conv.id, created.id); conventionsAdded++; } @@ -77,15 +82,15 @@ export async function importData(payload: ExportPayload): Promise skipped++; continue; } - // Only import if convention exists - const convExists = existingAllConventions.some((c) => c.id === event.conventionId); - if (!convExists) { + // Resolve the convention ID via the mapping + const mappedConventionId = idMap.get(event.conventionId); + if (!mappedConventionId) { skipped++; continue; } await eventsRepo.batchInsert([ { - conventionId: event.conventionId, + conventionId: mappedConventionId, title: event.title, description: event.description, startTime: event.startTime, diff --git a/apps/web/.env.example b/apps/web/.env.example index 9949560..9230cb5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,6 @@ -NEXT_PUBLIC_API_URL=https://api.conpaws.com +# API endpoint for lead capture form +NEXT_PUBLIC_API_URL= + +# Beta links (leave empty to hide beta section) +NEXT_PUBLIC_TESTFLIGHT_URL= +NEXT_PUBLIC_GOOGLE_PLAY_BETA_URL= diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 7d01c08..8f2d5a9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,7 +1,11 @@ 'use client'; import { useState } from 'react'; -import { Calendar, Share2, WifiOff, Github } from 'lucide-react'; +import { Calendar, Share2, WifiOff, Github, Smartphone, MonitorSmartphone } from 'lucide-react'; + +const TESTFLIGHT_URL = process.env.NEXT_PUBLIC_TESTFLIGHT_URL ?? ''; +const GOOGLE_PLAY_BETA_URL = process.env.NEXT_PUBLIC_GOOGLE_PLAY_BETA_URL ?? ''; +const hasBeta = TESTFLIGHT_URL !== '' || GOOGLE_PLAY_BETA_URL !== ''; const FEATURES = [ { @@ -75,12 +79,12 @@ export default function HomePage() { ConPaws

- Navigate, Connect, Enjoy — Your furry convention companion + An open source furry convention companion app, coming soon to iOS and Android.

- Coming to iOS & Android + Coming to App Store & Google Play
@@ -104,7 +108,43 @@ export default function HomePage() { - {/* Beta signup */} + {/* Beta links (shown when env vars are set) */} + {hasBeta && ( +
+
+

Join the Beta

+

+ ConPaws is in public beta. Try it now on your device. +

+
+ {TESTFLIGHT_URL && ( + + + Join iOS Beta + + )} + {GOOGLE_PLAY_BETA_URL && ( + + + Join Android Beta + + )} +
+
+
+ )} + + {/* Lead capture signup */}
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 0fd8081..ebf7fec 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -36,14 +36,19 @@ export default function PrivacyPage() {

3. Third-Party Services

- RevenueCat — used for in-app subscription - management (ConPaws+ features). RevenueCat may collect purchase receipts and - subscription status. See their privacy policy at revenuecat.com. + RevenueCat — will be used for in-app + subscription management (ConPaws+ features) in a future update. When implemented, + RevenueCat may collect purchase receipts and subscription status. See their privacy + policy at revenuecat.com.

- Supabase — used for optional account sync - (Phase 2+). If you create an account, your profile and schedule are synced to our - self-hosted Supabase instance. + Supabase — will be used for optional account + sync in a future update (Phase 2+). When implemented, if you create an account, your + profile and schedule will be synced to our self-hosted Supabase instance. +

+

+ These integrations are not yet active. This section will be updated when they are + implemented.

From 5b5a0d15487bd3faac5bdd364e8db1c41f8c28db Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:02:15 -0500 Subject: [PATCH 4/5] fix: wire up notifications, harden imports, and improve accessibility - Connect setReminderMutation to scheduleEventReminder/cancelEventReminder so OS notifications are actually scheduled when users set reminders - Fix AsyncStorage onboarding check to compare against 'true' explicitly and add error handling so the UI doesn't get stuck - Include RECURRENCE-ID in iCal dedupe key to preserve recurring instances - Wrap data import in a DB transaction for atomicity on partial failures - Resolve null on import cancel instead of a zeroed result object - Remove hardcoded api.conpaws.com fallback; fail fast if env var missing - Add accessible labels, aria-live feedback region, and aria-hidden honeypot - Alphabetize .env.example keys and fix README grammar/markdown lint Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- apps/native/app/convention/[id].tsx | 10 ++ apps/native/app/index.tsx | 10 +- apps/native/src/lib/ical-parser.ts | 6 +- apps/native/src/services/data-import.ts | 118 ++++++++++++------------ apps/web/.env.example | 2 +- apps/web/src/app/page.tsx | 34 ++++--- 7 files changed, 107 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 760f3e5..8abc4cc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/MrDemonWolf/conpaws/blob/main/LICENSE) -ConPaws is an open source furry convention companion app built by [MrDemonWolf](https://github.com/MrDemonWolf), coming soon to the Apple App Store and Google Play Store. Feel free to fork it and adapt it for your own conventions or fandom community. +ConPaws is an open-source furry convention companion app built by [MrDemonWolf](https://github.com/MrDemonWolf), coming soon to the Apple App Store and Google Play Store. Feel free to fork it and adapt it for your own conventions or fandom community. ## What it does @@ -13,7 +13,7 @@ ConPaws is an open source furry convention companion app built by [MrDemonWolf]( ## Project Structure -``` +```text conpaws/ ├── apps/mobile/ # Expo React Native app (iOS, Android, Web) ├── apps/web/ # Next.js static site → conpaws.com @@ -45,4 +45,4 @@ bun prebuild:clean # Clean and regenerate native projects ## Want to use this for your own fandom? -Fork it. The app is built to be self-hostable — swap in your own Supabase instance, configure your RevenueCat keys, and point it at your conventions. The iCal import works with any standard `.ics` file, so it should work with most convention schedule tools out of the box. +Fork it. The app is local-first with all core features working offline. Supabase (cloud sync) is optional and planned for Phase 2+, so you can self-host with Supabase if you want cloud sync. Configure your RevenueCat keys for premium features, and point it at your conventions. The iCal import works with any standard `.ics` file, so it should work with most convention schedule tools out of the box. diff --git a/apps/native/app/convention/[id].tsx b/apps/native/app/convention/[id].tsx index 03bc4a3..5d90cee 100644 --- a/apps/native/app/convention/[id].tsx +++ b/apps/native/app/convention/[id].tsx @@ -19,6 +19,7 @@ import { CategoryPill } from '@/components/CategoryPill'; import { SectionHeader } from '@/components/SectionHeader'; import * as conventionsRepo from '@/db/repositories/conventions'; import * as eventsRepo from '@/db/repositories/events'; +import { scheduleEventReminder, cancelEventReminder } from '@/services/notifications'; import type { ConventionEvent } from '@/db/schema'; import { format, isSameDay } from 'date-fns'; @@ -208,6 +209,15 @@ export default function ConventionDetailScreen() { const setReminderMutation = useMutation({ mutationFn: async ({ event, minutes }: { event: ConventionEvent; minutes: number | null }) => { await eventsRepo.update(event.id, { reminderMinutes: minutes }); + + if (minutes !== null && event.startTime) { + await scheduleEventReminder( + { id: event.id, title: event.title, startTime: event.startTime, room: event.room }, + minutes, + ); + } else { + await cancelEventReminder(event.id); + } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['events', id] }); diff --git a/apps/native/app/index.tsx b/apps/native/app/index.tsx index c4a67be..77a8e0c 100644 --- a/apps/native/app/index.tsx +++ b/apps/native/app/index.tsx @@ -7,9 +7,13 @@ export default function Index() { const [hasOnboarded, setHasOnboarded] = useState(null); useEffect(() => { - AsyncStorage.getItem("hasCompletedOnboarding").then((value) => { - setHasOnboarded(!!value); - }); + AsyncStorage.getItem("hasCompletedOnboarding") + .then((value) => { + setHasOnboarded(value === "true"); + }) + .catch(() => { + setHasOnboarded(false); + }); }, []); if (hasOnboarded === null) return ; diff --git a/apps/native/src/lib/ical-parser.ts b/apps/native/src/lib/ical-parser.ts index 1636d0d..be7c2d3 100644 --- a/apps/native/src/lib/ical-parser.ts +++ b/apps/native/src/lib/ical-parser.ts @@ -192,8 +192,10 @@ export function parseIcs(raw: string): ParseResult { const uid = props['UID']; if (!uid) continue; - if (seenUids.has(uid)) continue; - seenUids.add(uid); + const recurrenceId = props['RECURRENCE-ID'] ?? ''; + const dedupeKey = recurrenceId ? `${uid}|${recurrenceId}` : uid; + if (seenUids.has(dedupeKey)) continue; + seenUids.add(dedupeKey); const rawSummary = props['SUMMARY'] ?? ''; const title = decodeHtmlEntities(unescapeText(rawSummary)).trim(); diff --git a/apps/native/src/services/data-import.ts b/apps/native/src/services/data-import.ts index 6692f89..b38226b 100644 --- a/apps/native/src/services/data-import.ts +++ b/apps/native/src/services/data-import.ts @@ -4,6 +4,7 @@ import { Alert } from 'react-native'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as conventionsRepo from '@/db/repositories/conventions'; import * as eventsRepo from '@/db/repositories/events'; +import { db } from '@/db'; import type { Convention, ConventionEvent } from '@/db/schema'; import type { ExportPayload } from './data-export'; @@ -42,73 +43,72 @@ export async function importData(payload: ExportPayload): Promise const existingConventions = await conventionsRepo.getAll(); const existingIds = new Set(existingConventions.map((c) => c.id)); + const allEventIds = new Set(); + for (const conv of existingConventions) { + const events = await eventsRepo.getByConventionId(conv.id); + events.forEach((e) => allEventIds.add(e.id)); + } + let conventionsAdded = 0; let eventsAdded = 0; let skipped = 0; - const now = new Date().toISOString(); - // Track old export ID → new DB ID for newly created conventions const idMap = new Map(); - // Import conventions (skip existing IDs) - for (const conv of payload.data.conventions) { - if (existingIds.has(conv.id)) { - idMap.set(conv.id, conv.id); - skipped++; - continue; + await db.transaction(async () => { + // Import conventions (skip existing IDs) + for (const conv of payload.data.conventions) { + if (existingIds.has(conv.id)) { + idMap.set(conv.id, conv.id); + skipped++; + continue; + } + const created = await conventionsRepo.create({ + name: conv.name, + startDate: conv.startDate, + endDate: conv.endDate, + icalUrl: conv.icalUrl, + status: conv.status, + }); + idMap.set(conv.id, created.id); + conventionsAdded++; } - const created = await conventionsRepo.create({ - name: conv.name, - startDate: conv.startDate, - endDate: conv.endDate, - icalUrl: conv.icalUrl, - status: conv.status, - }); - idMap.set(conv.id, created.id); - conventionsAdded++; - } - // Import events (skip existing IDs) - const existingAllConventions = await conventionsRepo.getAll(); - const allEventIds = new Set(); - for (const conv of existingAllConventions) { - const events = await eventsRepo.getByConventionId(conv.id); - events.forEach((e) => allEventIds.add(e.id)); - } - - for (const event of payload.data.events) { - if (allEventIds.has(event.id)) { - skipped++; - continue; - } - // Resolve the convention ID via the mapping - const mappedConventionId = idMap.get(event.conventionId); - if (!mappedConventionId) { - skipped++; - continue; + // Import events (skip existing IDs) + for (const event of payload.data.events) { + if (allEventIds.has(event.id)) { + skipped++; + continue; + } + // Resolve the convention ID via the mapping + const mappedConventionId = idMap.get(event.conventionId); + if (!mappedConventionId) { + skipped++; + continue; + } + await eventsRepo.batchInsert([ + { + conventionId: mappedConventionId, + title: event.title, + description: event.description, + startTime: event.startTime, + endTime: event.endTime, + location: event.location, + room: event.room, + category: event.category, + type: event.type, + isInSchedule: event.isInSchedule, + reminderMinutes: event.reminderMinutes, + sourceUid: event.sourceUid, + sourceUrl: event.sourceUrl, + isAgeRestricted: event.isAgeRestricted, + contentWarning: event.contentWarning, + }, + ]); + eventsAdded++; } - await eventsRepo.batchInsert([ - { - conventionId: mappedConventionId, - title: event.title, - description: event.description, - startTime: event.startTime, - endTime: event.endTime, - location: event.location, - room: event.room, - category: event.category, - type: event.type, - isInSchedule: event.isInSchedule, - reminderMinutes: event.reminderMinutes, - sourceUid: event.sourceUid, - sourceUrl: event.sourceUrl, - isAgeRestricted: event.isAgeRestricted, - contentWarning: event.contentWarning, - }, - ]); - eventsAdded++; - } + }); return { conventionsAdded, eventsAdded, skipped }; } @@ -140,12 +140,12 @@ export function useImportData() { const convCount = payload.data.conventions.length; const eventCount = payload.data.events.length; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { Alert.alert( 'Import Data', `Import ${convCount} convention${convCount !== 1 ? 's' : ''} and ${eventCount} event${eventCount !== 1 ? 's' : ''}?`, [ - { text: 'Cancel', style: 'cancel', onPress: () => resolve({ conventionsAdded: 0, eventsAdded: 0, skipped: 0 }) }, + { text: 'Cancel', style: 'cancel', onPress: () => resolve(null) }, { text: 'Import', onPress: async () => { diff --git a/apps/web/.env.example b/apps/web/.env.example index 9230cb5..3ea3ab7 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,5 +2,5 @@ NEXT_PUBLIC_API_URL= # Beta links (leave empty to hide beta section) -NEXT_PUBLIC_TESTFLIGHT_URL= NEXT_PUBLIC_GOOGLE_PLAY_BETA_URL= +NEXT_PUBLIC_TESTFLIGHT_URL= diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 8f2d5a9..39ff2d2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -46,7 +46,11 @@ export default function HomePage() { setForm({ status: 'loading', message: '' }); try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.conpaws.com'; + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl) { + setForm({ status: 'error', message: 'Signup is not configured yet. Please try again later.' }); + return; + } const res = await fetch(`${apiUrl}/subscribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -154,13 +158,19 @@ export default function HomePage() {

- {form.status === 'success' ? ( -
-

{form.message}

-
- ) : ( +
+ {form.status === 'success' && ( +
+

{form.message}

+
+ )} + {form.status === 'error' && ( +

{form.message}

+ )} +
+ {form.status !== 'success' && (
- {/* Honeypot — hidden from humans */} + {/* Honeypot — hidden from humans and assistive tech */} setHoneypot(e.target.value)} tabIndex={-1} autoComplete="off" - style={{ display: 'none' }} + aria-hidden="true" + style={{ position: 'absolute', left: '-9999px' }} /> + + - {form.status === 'error' && ( -

{form.message}

- )}