diff --git a/mobile/app/(app)/invite.tsx b/mobile/app/(app)/invite.tsx index 5051fec..3fa51a7 100644 --- a/mobile/app/(app)/invite.tsx +++ b/mobile/app/(app)/invite.tsx @@ -1,30 +1,33 @@ import { useMemo } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, View } from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import { Share2 } from "lucide-react-native"; +import { ChevronRight, PawPrint } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useTheme } from "../../src/context/ThemeContext"; import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding"; import { useReferralRanks } from "../../src/hooks/useReferralRanks"; -import { env } from "../../src/lib/env"; import { PetAvatar } from "../../src/components/PetAvatar"; +import { Reveal } from "../../src/components/Reveal"; +import { InviteCard } from "../../src/components/referrals/InviteCard"; import { RankLadder } from "../../src/components/referrals/RankLadder"; import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm"; -import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens"; +import { fontFamily, getTypography, type ColorTokens } from "../../src/theme/tokens"; -const STATUS_KEYS = { +type InviteeStatus = "counted" | "waiting" | "in-review" | "revoked"; + +const STATUS_KEYS: Record = { counted: "referrals.statusCounted", waiting: "referrals.statusWaiting", "in-review": "referrals.statusInReview", revoked: "referrals.statusRevoked", -} as const; +}; /** - * Port of web's /einladen — eigener Link mit nativem Teilen-Dialog (React - * Natives eingebautes Share, kein zusätzliches natives Modul → per OTA - * auslieferbar), Rangfortschritt, eingeladene Tiere, nachträgliche Code-Eingabe. + * Port of web's /einladen in the same look as the settings card: InviteCard + * on top (rank, code, progress, native share — no extra native module, OTA- + * safe), rank timeline, invited pets with status pills, late code entry. */ export default function InviteScreen() { const { t } = useTranslation(); @@ -44,12 +47,12 @@ export default function InviteScreen() { ); } - const link = `${env.apiUrl}/r/${data.code}`; - const achieved = new Set(data.achievedRankIds); - - function handleShare() { - void Share.share({ message: `${t("referrals.shareText", { code: data!.code })} ${link}` }); - } + const statusColor: Record = { + counted: "#059669", + waiting: colors.marigoldOrange, + "in-review": colors.quietGray, + revoked: colors.alertRed, + }; return ( - {t("referrals.subtitle")} + {t("referrals.subtitle")} {!data.programActive && {t("referrals.programPaused")}} - - {t("referrals.shareHeading")} - {t("referrals.shareSubtitle")} - - {t("referrals.yourCode")} - {data.code} + + + + + + + + + {t("referrals.inviteesHeading")} + {data.invitees.length === 0 ? ( + + + + + {t("referrals.inviteesEmpty")} + + ) : ( + data.invitees.map((invitee, index) => { + const status = invitee.status as InviteeStatus; + return ( + [styles.inviteeRow, pressed && invitee.pet && styles.pressed]} + disabled={!invitee.pet} + onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)} + > + {invitee.pet ? ( + + ) : ( + + )} + + {invitee.pet?.name ?? t("referrals.inviteeNoPet")} + + + {t(STATUS_KEYS[status])} + + {invitee.pet ? : null} + + ); + }) + )} - - - {t("referrals.share")} - - - - - - - {t("referrals.inviteesHeading")} - {data.invitees.length === 0 ? ( - {t("referrals.inviteesEmpty")} - ) : ( - data.invitees.map((invitee, index) => ( - invitee.pet && router.push(`/pets/${invitee.pet.id}`)} - > - {invitee.pet && } - {invitee.pet?.name ?? t("referrals.inviteeNoPet")} - {t(STATUS_KEYS[invitee.status])} - - )) - )} - + {data.canRedeem && ( - - {t("referrals.redeemHeading")} - {t("referrals.redeemSubtitle")} - - + + + {t("referrals.redeemHeading")} + {t("referrals.redeemSubtitle")} + + + )} {t("referrals.rulesNote")} @@ -114,18 +134,20 @@ function makeStyles(colors: ColorTokens) { return StyleSheet.create({ center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }, content: { padding: 16, gap: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, - subtitle: { ...typography.body, color: colors.quietGray }, - notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: radii.xl, backgroundColor: colors.marigoldTint, color: colors.ink }, - card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite }, + intro: { ...typography.body, fontSize: 15, lineHeight: 22, color: colors.quietGray }, + notice: { ...typography.body, fontSize: 13, padding: 12, borderRadius: 14, backgroundColor: colors.marigoldTint, color: colors.ink }, + card: { gap: 12, padding: 18, borderRadius: 22, borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite }, heading: { ...typography.title, fontSize: 16 }, muted: { ...typography.body, fontSize: 13, color: colors.quietGray }, - codeBox: { alignItems: "center", paddingVertical: 12, borderRadius: radii.xl, backgroundColor: colors.whisperGray }, - codeLabel: { ...typography.body, fontSize: 11, letterSpacing: 1, textTransform: "uppercase", color: colors.quietGray }, - code: { ...typography.title, fontSize: 24, letterSpacing: 2 }, - shareButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange }, - shareText: { ...typography.body, fontWeight: "600", color: "#ffffff" }, + empty: { flexDirection: "row", alignItems: "center", gap: 12 }, + emptyIcon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" }, + emptyText: { ...typography.body, flex: 1, fontSize: 13.5, lineHeight: 19, color: colors.quietGray }, inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }, - inviteeName: { ...typography.body, flex: 1, fontWeight: "600" }, - footnote: { ...typography.body, fontSize: 12, color: colors.quietGray }, + pressed: { opacity: 0.7 }, + noPetAvatar: { width: 32, height: 32, borderRadius: 16, borderWidth: 1.5, borderStyle: "dashed", borderColor: colors.hairlineGray }, + inviteeName: { flex: 1, fontFamily: fontFamily.semiBold, fontSize: 14.5, color: colors.ink }, + statusPill: { paddingHorizontal: 9, paddingVertical: 3, borderRadius: 999, borderWidth: 1 }, + statusText: { fontFamily: fontFamily.semiBold, fontSize: 11.5 }, + footnote: { ...typography.body, fontSize: 12, lineHeight: 18, color: colors.quietGray }, }); } diff --git a/mobile/app/(auth)/sign-in.tsx b/mobile/app/(auth)/sign-in.tsx index 2a73d18..9b1c6cf 100644 --- a/mobile/app/(auth)/sign-in.tsx +++ b/mobile/app/(auth)/sign-in.tsx @@ -9,7 +9,7 @@ import { GoogleSignInButton } from "../../src/components/GoogleSignInButton"; import { AppleSignInButton } from "../../src/components/AppleSignInButton"; import { AuthDivider, AuthFooterLink, AuthShell } from "../../src/components/auth/AuthShell"; import { AuthField } from "../../src/components/auth/AuthField"; -import { Reveal } from "../../src/components/auth/Reveal"; +import { Reveal } from "../../src/components/Reveal"; import { useShake } from "../../src/hooks/useShake"; import { useTheme } from "../../src/context/ThemeContext"; import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens"; diff --git a/mobile/app/(auth)/sign-up.tsx b/mobile/app/(auth)/sign-up.tsx index 0699f6d..16aea98 100644 --- a/mobile/app/(auth)/sign-up.tsx +++ b/mobile/app/(auth)/sign-up.tsx @@ -9,7 +9,7 @@ import { GoogleSignInButton } from "../../src/components/GoogleSignInButton"; import { AppleSignInButton } from "../../src/components/AppleSignInButton"; import { AuthDivider, AuthFooterLink, AuthShell } from "../../src/components/auth/AuthShell"; import { AuthField } from "../../src/components/auth/AuthField"; -import { Reveal } from "../../src/components/auth/Reveal"; +import { Reveal } from "../../src/components/Reveal"; import { useShake } from "../../src/hooks/useShake"; import { useTheme } from "../../src/context/ThemeContext"; import { env } from "../../src/lib/env"; diff --git a/mobile/app/onboarding/_layout.tsx b/mobile/app/onboarding/_layout.tsx index aabc888..fa74a9b 100644 --- a/mobile/app/onboarding/_layout.tsx +++ b/mobile/app/onboarding/_layout.tsx @@ -1,42 +1,13 @@ -import { Pressable } from "react-native"; -import { Stack, useRouter } from "expo-router"; -import { X } from "lucide-react-native"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "../../src/context/ThemeContext"; -import { fontFamily } from "../../src/theme/tokens"; +import { Stack } from "expo-router"; /** - * Always closes back to /(app), never router.back() — this screen is - * reached two ways: OnboardingRedirect's replace() (no meaningful back - * history) for a zero-pet owner, and the Pets tab's "+" button (real back - * history) for an existing owner adding another pet. A zero-pet owner who - * closes gets bounced right back here by OnboardingRedirect — that's - * correct, not a bug: they can't use the app without a first pet, but they - * get an obvious "why am I back here" instead of a dead-end screen with no - * way to leave at all. + * No native header: the onboarding draws its own top bar (back, progress, + * close) in pet.tsx. Close always goes to /(app), never router.back() — this + * screen is reached via OnboardingRedirect's replace() (zero pets, no back + * history) and via the Pets tab's "+" (another pet). A zero-pet owner who + * closes gets bounced right back by OnboardingRedirect — intended: they can't + * use the app without a first pet. */ -function CloseButton() { - const router = useRouter(); - const { colors } = useTheme(); - return ( - router.replace("/(app)")} hitSlop={12} style={{ marginLeft: 8 }}> - - - ); -} - export default function OnboardingLayout() { - const { t } = useTranslation(); - const { colors } = useTheme(); - return ( - , - }} - > - - - ); + return ; } diff --git a/mobile/app/onboarding/pet.tsx b/mobile/app/onboarding/pet.tsx index 9c5c5d9..969627e 100644 --- a/mobile/app/onboarding/pet.tsx +++ b/mobile/app/onboarding/pet.tsx @@ -1,102 +1,241 @@ -import { useState } from "react"; -import { ActivityIndicator, FlatList, View } from "react-native"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, BackHandler, KeyboardAvoidingView, StyleSheet, View } from "react-native"; import { useRouter } from "expo-router"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useActivePet } from "../../src/context/ActivePetContext"; -import { OnboardingGuidelines } from "../../src/components/onboarding/OnboardingGuidelines"; -import { UploadConsent } from "../../src/components/onboarding/UploadConsent"; -import { ArachnophobiaOptIn } from "../../src/components/onboarding/ArachnophobiaOptIn"; -import { PetCreateForm } from "../../src/components/onboarding/PetCreateForm"; -import { AvatarStep } from "../../src/components/onboarding/AvatarStep"; -import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep"; -import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "../../src/api/trpc"; import { useTheme } from "../../src/context/ThemeContext"; +import type { PickerOption } from "../../src/components/OptionPicker"; +import { ArachnophobiaOptIn } from "../../src/components/onboarding/ArachnophobiaOptIn"; +import { AvatarStep } from "../../src/components/onboarding/AvatarStep"; +import { AcquisitionStep } from "../../src/components/onboarding/AcquisitionStep"; import { CaretakerOnboarding } from "../../src/components/caretaker/CaretakerOnboarding"; -import { Button } from "../../src/components/Button"; -import { useTranslation } from "react-i18next"; +import { OnboardingTopBar } from "../../src/components/onboarding/flow/OnboardingTopBar"; +import { StepSlide } from "../../src/components/onboarding/flow/StepSlide"; +import { WelcomeStep } from "../../src/components/onboarding/flow/WelcomeStep"; +import { NameStep } from "../../src/components/onboarding/flow/NameStep"; +import { SpeciesStep } from "../../src/components/onboarding/flow/SpeciesStep"; +import { AboutStep } from "../../src/components/onboarding/flow/AboutStep"; +import { DoneStep } from "../../src/components/onboarding/flow/DoneStep"; + +type StepId = "welcome" | "name" | "species" | "about" | "photo" | "spiders" | "source" | "done"; + +// ponytail: matched by name because pets.listSpecies has no canonical key; the +// taxonomy only has en/de. Add a key to listSpecies once a third locale exists. +const SPIDER_NAMES = new Set(["spider", "spinne"]); /** - * Port of web's src/app/onboarding/pet/page.tsx — same step order: - * community guidelines -> upload-rights/data-processing consent -> - * Arachnophobie-Helfer opt-in -> pet profile -> "Wie hast du von PawFeed - * erfahren?" + referral code (once per owner) -> avatar photo. Lives as - * its own top-level route (not under (auth) or (app)) so app/_layout.tsx's - * "zero pets -> redirect here" check (see OnboardingRedirect) doesn't loop. + * Onboarding as short steps (web's order kept where it matters): welcome + * (rules + both declarations) -> name -> species/breed -> bio/story (creates + * the pet) -> photo -> spider filter -> "Wie hast du von PawFeed erfahren?" + * -> closing moment. Lives as its own top-level route (not under (auth) or + * (app)) so app/_layout.tsx's "zero pets -> redirect here" check doesn't loop. + * + * The legal queries are read once (staleTime Infinity) and only invalidated + * when leaving: refetching mid-flow (e.g. after the camera backgrounds the + * app) would drop the welcome step and shift the progress bar. */ export default function OnboardingPetScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); const { colors } = useTheme(); - const { setActivePetId } = useActivePet(); - const [guidelinesAcknowledged, setGuidelinesAcknowledged] = useState(false); - const [uploadConsentGiven, setUploadConsentGiven] = useState(false); - const [arachnophobiaStepDone, setArachnophobiaStepDone] = useState(false); - const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null); - const [acquisitionStepDone, setAcquisitionStepDone] = useState(false); - // „Ich betreue ein Tier mit“ statt eigenes Tier anlegen (Mitbetreuer-Einladung) - const [isCaretakerPath, setIsCaretakerPath] = useState(false); - const { t } = useTranslation(); + const { pets, setActivePetId } = useActivePet(); const trpc = useTRPC(); - // Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht. + const queryClient = useQueryClient(); + + const [isFirstPet] = useState(() => pets.length === 0); + const [stepId, setStepId] = useState("welcome"); + const [direction, setDirection] = useState<1 | -1>(1); + const [isCaretakerPath, setIsCaretakerPath] = useState(false); + + const [name, setName] = useState(""); + const [nickname, setNickname] = useState(""); + const [species, setSpecies] = useState(null); + const [breed, setBreed] = useState(null); + const [bio, setBio] = useState(""); + const [adoptionStory, setAdoptionStory] = useState(""); + const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null); + const [createError, setCreateError] = useState(null); + + const pendingLegal = useQuery({ ...trpc.legal.getPendingUpdate.queryOptions(), staleTime: Infinity }); + const consentStatus = useQuery({ ...trpc.legal.getUploadConsentStatus.queryOptions(), staleTime: Infinity }); const { data: referralState } = useQuery({ ...trpc.referrals.onboardingState.queryOptions(), + // Only after the pet exists — before that the Owner row may not exist yet. enabled: !!createdPet, }); - const isReferralStateLoading = !!createdPet && !acquisitionStepDone && referralState === undefined; - const showAcquisitionStep = !!createdPet && !acquisitionStepDone && !!referralState?.showStep; - const finish = () => router.replace("/(app)"); + const needsLegal = (pendingLegal.data?.length ?? 0) > 0; + const needsConsent = consentStatus.data ? !consentStatus.data.hasConsented : false; + const isSpider = !!species && SPIDER_NAMES.has(species.name.toLowerCase()); - let content: React.ReactNode; - if (!guidelinesAcknowledged) { - content = setGuidelinesAcknowledged(true)} />; - } else if (!uploadConsentGiven) { - content = setUploadConsentGiven(true)} />; - } else if (!arachnophobiaStepDone) { - content = setArachnophobiaStepDone(true)} />; - } else if (isReferralStateLoading) { - content = ( - - - - ); - } else if (showAcquisitionStep) { - content = setAcquisitionStepDone(true)} />; - } else if (createdPet) { - content = ; + const steps: StepId[] = [ + ...(needsLegal || needsConsent ? (["welcome"] as const) : []), + "name", + "species", + "about", + "photo", + // Shown for every new pet: the app has no other place to change it (web: /pets). + ...(isSpider ? [] : (["spiders"] as const)), + ...(isFirstPet ? (["source"] as const) : []), + ]; + const current: StepId = stepId === "welcome" && !steps.includes("welcome") ? "name" : stepId; + const index = steps.indexOf(current); + + const go = useCallback((target: StepId, dir: 1 | -1) => { + setDirection(dir); + setStepId(target); + }, []); + const next = () => go(steps[index + 1] ?? "done", 1); + // No way back past pet creation — the pet exists, going back would create a second one. + const back = index > 0 && !createdPet ? () => go(steps[index - 1], -1) : undefined; + + const leave = () => { + queryClient.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryOptions().queryKey }); + queryClient.invalidateQueries({ queryKey: trpc.legal.getUploadConsentStatus.queryOptions().queryKey }); + router.replace("/(app)"); + }; + + // Android back gesture: one step back instead of leaving the onboarding. + const goBackInFlow = isCaretakerPath ? () => setIsCaretakerPath(false) : back; + const goBackRef = useRef(goBackInFlow); + useEffect(() => { + goBackRef.current = goBackInFlow; + }); + useEffect(() => { + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + if (!goBackRef.current) return false; + goBackRef.current(); + return true; + }); + return () => subscription.remove(); + }, []); + + const createMutation = useMutation( + trpc.pets.create.mutationOptions({ + onSuccess: (pet) => { + // Without this, ActivePetContext keeps "no pets" and OnboardingRedirect + // bounces straight back here once the flow ends. + queryClient.invalidateQueries({ queryKey: trpc.pets.list.queryOptions().queryKey }); + setActivePetId(pet.id); + setCreatedPet({ id: pet.id, name: pet.name }); + go("photo", 1); + }, + onError: (err) => setCreateError(err.message), + }), + ); + + const createPet = () => { + if (!species) return; + setCreateError(null); + createMutation.mutate({ + name: name.trim(), + nickname: nickname.trim() || undefined, + speciesId: species.id, + breedId: breed?.id, + bio: bio.trim() || undefined, + adoptionStory: adoptionStory.trim() || undefined, + }); + }; + + const petName = createdPet?.name ?? (name.trim() || "?"); + const isLoadingLegal = pendingLegal.isLoading || consentStatus.isLoading; + + let content: ReactNode; + if (isLoadingLegal) { + content = ; } else if (isCaretakerPath) { - content = setIsCaretakerPath(false)} />; - } else { + content = setIsCaretakerPath(false)} />; + } else if (current === "welcome") { + content = ; + } else if (current === "name") { content = ( - <> - { - setActivePetId(pet.id); - setCreatedPet(pet); - }} - /> - -