diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c3691..689eda4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,12 @@ jobs: - name: Lint run: bun run lint - working-directory: apps/mobile + working-directory: apps/native - name: Type check run: bun run type-check - working-directory: apps/mobile + working-directory: apps/native - name: Test run: bun test - working-directory: apps/mobile + working-directory: apps/native diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml new file mode 100644 index 0000000..fa482c2 --- /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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - 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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + 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 diff --git a/README.md b/README.md index c310251..8abc4cc 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 +```text +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 ``` -Then, run the development server: +## Getting Started ```bash -bun run dev +bun install ``` -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: +### Mobile app (`apps/mobile`) ```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"; -``` - -### Add app-specific blocks - -If you want to add app-specific blocks instead of shared primitives, run the shadcn CLI from `apps/web`. - -## Project Structure - +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 ``` -conpaws/ -├── apps/ -│ ├── web/ # Frontend application (Next.js) -│ ├── native/ # Mobile application (React Native, Expo) -├── packages/ -│ ├── ui/ # Shared shadcn/ui components and styles -``` - -## 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 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/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..795ff75 --- /dev/null +++ b/apps/native/app/(onboarding)/complete.tsx @@ -0,0 +1,42 @@ +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() { + try { + await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); + } catch { + // Storage write failed — still navigate so user isn't stuck + } finally { + 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..922d656 --- /dev/null +++ b/apps/native/app/(tabs)/profile.tsx @@ -0,0 +1,51 @@ +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 */} + + + + + + + + {/* Google Sign In */} + + + + + + + + + + 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..5d90cee --- /dev/null +++ b/apps/native/app/convention/[id].tsx @@ -0,0 +1,383 @@ +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 { scheduleEventReminder, cancelEventReminder } from '@/services/notifications'; +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 }); + + 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] }); + }, + }); + + 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..77a8e0c 100644 --- a/apps/native/app/index.tsx +++ b/apps/native/app/index.tsx @@ -1,15 +1,22 @@ -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 === "true"); + }) + .catch(() => { + setHasOnboarded(false); + }); + }, []); + + 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..be7c2d3 --- /dev/null +++ b/apps/native/src/lib/ical-parser.ts @@ -0,0 +1,263 @@ +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; + 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(); + 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..b38226b --- /dev/null +++ b/apps/native/src/services/data-import.ts @@ -0,0 +1,181 @@ +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 { db } from '@/db'; +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)); + + 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; + + // Track old export ID → new DB ID for newly created conventions + const idMap = new Map(); + + 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++; + } + + // 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++; + } + }); + + 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(null) }, + { + 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..3ea3ab7 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,6 @@ +# API endpoint for lead capture form +NEXT_PUBLIC_API_URL= + +# Beta links (leave empty to hide beta section) +NEXT_PUBLIC_GOOGLE_PLAY_BETA_URL= +NEXT_PUBLIC_TESTFLIGHT_URL= 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..39ff2d2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,30 +1,245 @@ -"use client"; - -const TITLE_TEXT = ` - ██████╗ ███████╗████████╗████████╗███████╗██████╗ - ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ - ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ - ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ - ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ - ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ - - ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ - ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ - ██║ ███████╗ ██║ ███████║██║ █████╔╝ - ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ - ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ - ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ - `; - -export default function Home() { +'use client'; + +import { useState } from '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 = [ + { + 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; + 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' }, + 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 +

+

+ An open source furry convention companion app, coming soon to iOS and Android. +

+
+
+ + Coming to App Store & Google Play +
+
+ + {/* Features */} +
+
+ {FEATURES.map((feature) => ( +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+ + {/* 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 */} +
+
+
+

Get Early Access

+

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

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

{form.message}

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

{form.message}

+ )} +
+ {form.status !== 'success' && ( +
+ {/* Honeypot — hidden from humans and assistive tech */} + setHoneypot(e.target.value)} + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + style={{ position: 'absolute', left: '-9999px' }} + /> + + 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" + /> + +
+ )} +
+
+ + {/* 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..ebf7fec --- /dev/null +++ b/apps/web/src/app/privacy/page.tsx @@ -0,0 +1,83 @@ +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 — 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 — 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. +

+
+ +
+

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..3f30756 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,20 +43,37 @@ "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", "react-native-web": "^0.21.2", + "react-native-worklets": "*", "tailwind-merge": "^3.4.0", "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", }, }, + "apps/server": { + "name": "@conpaws/server", + "version": "0.1.0", + "dependencies": { + "hono": "^4.7.12", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250622.0", + "typescript": "^5", + "wrangler": "^4.23.0", + }, + }, "apps/web": { "name": "web", "version": "0.1.0", @@ -302,7 +321,7 @@ "@babel/preset-react": ["@babel/preset-react@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-transform-react-display-name": "^7.28.0", "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ=="], - "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -318,14 +337,34 @@ "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], + "@conpaws/config": ["@conpaws/config@workspace:packages/config"], "@conpaws/env": ["@conpaws/env@workspace:packages/env"], "@conpaws/native": ["@conpaws/native@workspace:apps/native"], + "@conpaws/server": ["@conpaws/server@workspace:apps/server"], + "@conpaws/ui": ["@conpaws/ui@workspace:packages/ui"], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.55.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-WEuKyoe9CA7dfcFBnNbL0ndbCNcptaEYBygfFo9X1qEG+HD7xku4CYIplw6sbAHJavesZWbVBHeRSpvri0eKqw=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -372,15 +411,15 @@ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], @@ -594,6 +633,12 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -766,12 +811,16 @@ "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="], @@ -952,6 +1001,8 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -1140,6 +1191,8 @@ "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1180,11 +1233,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=="], @@ -1458,29 +1511,29 @@ "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1564,6 +1617,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "miniflare": ["miniflare@4.20260317.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-A3csI1HXEIfqe3oscgpoRMHdYlkReQKPH/g5JE53vFSjoM6YIAOGAzyDNeYffwd9oQkPWDj9xER8+vpxei8klA=="], + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2002,6 +2057,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], @@ -2072,6 +2129,10 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="], + + "wrangler": ["wrangler@4.76.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260317.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260317.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260317.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Wan+CU5a0tu4HIxGOrzjNbkmxCT27HUmzrMj6kc7aoAnjSLv50Ggcn2Ant7wNQrD6xW3g31phKupZJgTZ8wZfQ=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -2102,6 +2163,10 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -2122,6 +2187,8 @@ "@conpaws/ui/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2160,6 +2227,8 @@ "@expo/metro-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/metro-config/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], @@ -2174,6 +2243,8 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2230,6 +2301,8 @@ "babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + "babel-preset-expo/@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2258,8 +2331,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=="], @@ -2326,6 +2411,10 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "miniflare/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "msw/type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -2358,8 +2447,6 @@ "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], - "react-native-worklets/@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], - "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2370,6 +2457,8 @@ "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "shadcn/@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2398,6 +2487,8 @@ "web/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2470,6 +2561,28 @@ "@expo/cli/ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], + "@expo/metro-config/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "@expo/metro/metro-source-map/ob1": ["ob1@0.83.3", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA=="], "@expo/package-manager/ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -2530,8 +2643,6 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], @@ -2570,6 +2681,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=="], @@ -2632,10 +2749,16 @@ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], @@ -2644,6 +2767,52 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@expo/cli/ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "@expo/cli/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],