feat(mobile): Rest zu 0ec0583 – Onboarding-Schritte, InviteCard-Inhalt, Texte

Der vorige Commit enthielt durch einen fehlgeschlagenen git add nur die
Umbenennungen und Löschungen. Hier folgen die eigentlichen Änderungen:
neue Onboarding-Schritte (flow/), InviteCard/RankLadder/invite.tsx,
Reveal-Importpfade, DE/EN-Texte und der found.md-Eintrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 07:14:50 +02:00
1 parent 0ec05835ff
commit fdc41b7b69
25 files changed
+1616 -373

No files matched your search

+86 -64
View File
@@ -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<InviteeStatus, string> = {
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<InviteeStatus, string> = {
counted: "#059669",
waiting: colors.marigoldOrange,
"in-review": colors.quietGray,
revoked: colors.alertRed,
};
return (
<ScrollView
@@ -57,51 +60,68 @@ export default function InviteScreen() {
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
<Text style={styles.intro}>{t("referrals.subtitle")}</Text>
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.shareHeading")}</Text>
<Text style={styles.muted}>{t("referrals.shareSubtitle")}</Text>
<View style={styles.codeBox}>
<Text style={styles.codeLabel}>{t("referrals.yourCode")}</Text>
<Text style={styles.code} selectable>{data.code}</Text>
<InviteCard />
<Reveal index={2}>
<RankLadder
ranks={ranks}
achievedRankIds={new Set(data.achievedRankIds)}
qualifiedCount={data.qualifiedCount}
nextRankId={data.nextRankId}
/>
</Reveal>
<Reveal index={3}>
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
{data.invitees.length === 0 ? (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<PawPrint color={colors.marigoldOrange} size={22} />
</View>
<Text style={styles.emptyText}>{t("referrals.inviteesEmpty")}</Text>
</View>
) : (
data.invitees.map((invitee, index) => {
const status = invitee.status as InviteeStatus;
return (
<Pressable
key={invitee.pet?.id ?? `invitee-${index}`}
style={({ pressed }) => [styles.inviteeRow, pressed && invitee.pet && styles.pressed]}
disabled={!invitee.pet}
onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)}
>
{invitee.pet ? (
<PetAvatar name={invitee.pet.name} avatarKey={invitee.pet.avatarKey} size="sm" />
) : (
<View style={styles.noPetAvatar} />
)}
<Text style={styles.inviteeName} numberOfLines={1}>
{invitee.pet?.name ?? t("referrals.inviteeNoPet")}
</Text>
<View style={[styles.statusPill, { borderColor: statusColor[status] }]}>
<Text style={[styles.statusText, { color: statusColor[status] }]}>{t(STATUS_KEYS[status])}</Text>
</View>
{invitee.pet ? <ChevronRight size={16} color={colors.quietGray} /> : null}
</Pressable>
);
})
)}
</View>
<Pressable style={styles.shareButton} onPress={handleShare} accessibilityRole="button">
<Share2 size={18} color="#ffffff" />
<Text style={styles.shareText}>{t("referrals.share")}</Text>
</Pressable>
</View>
<RankLadder ranks={ranks} achievedRankIds={achieved} qualifiedCount={data.qualifiedCount} nextRankId={data.nextRankId} />
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.inviteesHeading")}</Text>
{data.invitees.length === 0 ? (
<Text style={styles.muted}>{t("referrals.inviteesEmpty")}</Text>
) : (
data.invitees.map((invitee, index) => (
<Pressable
key={invitee.pet?.id ?? `invitee-${index}`}
style={styles.inviteeRow}
disabled={!invitee.pet}
onPress={() => invitee.pet && router.push(`/pets/${invitee.pet.id}`)}
>
{invitee.pet && <PetAvatar name={invitee.pet.name} avatarKey={invitee.pet.avatarKey} size="sm" />}
<Text style={styles.inviteeName} numberOfLines={1}>{invitee.pet?.name ?? t("referrals.inviteeNoPet")}</Text>
<Text style={styles.muted}>{t(STATUS_KEYS[invitee.status])}</Text>
</Pressable>
))
)}
</View>
</Reveal>
{data.canRedeem && (
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
<RedeemCodeForm />
</View>
<Reveal index={4}>
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
<RedeemCodeForm />
</View>
</Reveal>
)}
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
@@ -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 },
});
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+8 -37
View File
@@ -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 (
<Pressable onPress={() => router.replace("/(app)")} hitSlop={12} style={{ marginLeft: 8 }}>
<X color={colors.ink} size={22} />
</Pressable>
);
}
export default function OnboardingLayout() {
const { t } = useTranslation();
const { colors } = useTheme();
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.pureWhite },
headerTitleStyle: { fontFamily: fontFamily.bold, color: colors.ink },
headerLeft: () => <CloseButton />,
}}
>
<Stack.Screen name="pet" options={{ title: t("onboarding.addPetTitle") }} />
</Stack>
);
return <Stack screenOptions={{ headerShown: false }} />;
}
+214 -75
View File
@@ -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<StepId>("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<PickerOption | null>(null);
const [breed, setBreed] = useState<PickerOption | null>(null);
const [bio, setBio] = useState("");
const [adoptionStory, setAdoptionStory] = useState("");
const [createdPet, setCreatedPet] = useState<{ id: string; name: string } | null>(null);
const [createError, setCreateError] = useState<string | null>(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 = <OnboardingGuidelines onContinue={() => setGuidelinesAcknowledged(true)} />;
} else if (!uploadConsentGiven) {
content = <UploadConsent onContinue={() => setUploadConsentGiven(true)} />;
} else if (!arachnophobiaStepDone) {
content = <ArachnophobiaOptIn onContinue={() => setArachnophobiaStepDone(true)} />;
} else if (isReferralStateLoading) {
content = (
<View style={{ padding: 48, alignItems: "center" }}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
} else if (showAcquisitionStep) {
content = <AcquisitionStep canRedeem={referralState?.canRedeem ?? false} onDone={() => setAcquisitionStepDone(true)} />;
} else if (createdPet) {
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={finish} />;
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 = <Centered color={colors.marigoldOrange} />;
} else if (isCaretakerPath) {
content = <CaretakerOnboarding onApproved={finish} onBack={() => setIsCaretakerPath(false)} />;
} else {
content = <CaretakerOnboarding onApproved={leave} onBack={() => setIsCaretakerPath(false)} />;
} else if (current === "welcome") {
content = <WelcomeStep needsLegal={needsLegal} needsConsent={needsConsent} onContinue={next} />;
} else if (current === "name") {
content = (
<>
<PetCreateForm
onSuccess={(pet) => {
setActivePetId(pet.id);
setCreatedPet(pet);
}}
/>
<View style={{ paddingHorizontal: 24, paddingBottom: 32 }}>
<Button title={t("caretaker.onboardingSwitch")} variant="outline" onPress={() => setIsCaretakerPath(true)} />
</View>
</>
<NameStep
name={name}
nickname={nickname}
onChangeName={setName}
onChangeNickname={setNickname}
onNext={next}
onCaretaker={() => setIsCaretakerPath(true)}
/>
);
} else if (current === "species") {
content = (
<SpeciesStep
petName={petName}
speciesId={species?.id ?? null}
breedName={breed?.name ?? null}
onSelectSpecies={(option) => {
setSpecies(option);
setBreed(null);
}}
onSelectBreed={setBreed}
onNext={next}
/>
);
} else if (current === "about") {
content = (
<AboutStep
petName={petName}
bio={bio}
adoptionStory={adoptionStory}
onChangeBio={setBio}
onChangeAdoptionStory={setAdoptionStory}
error={createError}
isCreating={createMutation.isPending}
onCreate={createPet}
/>
);
} else if (current === "photo" && createdPet) {
content = <AvatarStep petId={createdPet.id} petName={createdPet.name} onDone={next} />;
} else if (current === "spiders") {
content = <ArachnophobiaOptIn onContinue={next} />;
} else if (current === "source") {
content = !referralState ? (
<Centered color={colors.marigoldOrange} />
) : referralState.showStep ? (
<AcquisitionStep canRedeem={referralState.canRedeem} onDone={next} />
) : (
<AutoAdvance onAdvance={next} />
);
} else {
content = <DoneStep petName={petName} onFinish={leave} />;
}
return (
// FlatList, not ScrollView, as the scroll container — kept from the
// original nested-ScrollView fix (see git history) even though
// OptionPicker's dropdown now renders in a Modal instead of nesting a
// ScrollView here, so it's no longer strictly required for that
// specific reason; still the established, working scroll container for
// this screen.
<FlatList
data={EMPTY_DATA}
renderItem={null}
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
ListHeaderComponent={<>{content}</>}
/>
// "padding" on Android too: edge-to-edge means the window no longer resizes for the keyboard.
<KeyboardAvoidingView style={[styles.flex, { backgroundColor: colors.porchWhite }]} behavior="padding">
<View style={[styles.flex, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
{current !== "done" && !isCaretakerPath ? (
<OnboardingTopBar count={steps.length} filled={index + 1} onBack={back} onClose={leave} />
) : null}
<StepSlide key={isCaretakerPath ? "caretaker" : current} direction={direction}>
{content}
</StepSlide>
</View>
</KeyboardAvoidingView>
);
}
const EMPTY_DATA: never[] = [];
function Centered({ color }: { color: string }) {
return (
<View style={styles.center}>
<ActivityIndicator color={color} />
</View>
);
}
/** "Wie hast du von PawFeed erfahren?" is once per owner — skip straight on when the server says it's done. */
function AutoAdvance({ onAdvance }: { onAdvance: () => void }) {
useEffect(() => {
onAdvance();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
const styles = StyleSheet.create({
flex: { flex: 1 },
center: { flex: 1, alignItems: "center", justifyContent: "center" },
});
+3
View File
@@ -215,3 +215,6 @@ Neu 7:
- [x] Wunsch 09.10. (Obsidian „Mobile - UI Rework“): Login-Seite wirkt statisch, Sprachwahl zu dominant, Logo fehlt, Reihenfolge seltsam. Nachtrag: Nutzer versteht nicht, warum „Mit Google anmelden“ auf der Registrierung ausgegraut ist.
→ **Umgesetzt (09.10.):** Login und Registrierung neu aufgebaut (gemeinsamer Rahmen `src/components/auth/AuthShell.tsx`): Logo + „PawFeed“ oben, Sprache als kleiner „DE ⌄“-Chip mit Menü, Formular unten (E-Mail/Passwort → Anmelden → „oder weiter mit“ Apple/Google). Animationen nur mit eingebautem `Animated` (OTA-fähig): Logo federt rein, Inhalte gleiten gestaffelt ein, Feldrahmen orange bei Fokus, Ladekringel im Knopf, Felder wackeln + rot bei falschem Login; „Bewegung reduzieren“ wird beachtet. Android: Abstände über Statusleiste/Gestenleiste per Safe-Area, 28 pt Seitenrand (Zurück-Wischzone), Sprachmenü als Modal (Zurück-Geste schließt nur das Menü), Tastatur schiebt das Formular jetzt hoch (`KeyboardAvoidingView` auch auf Android, nötig seit Edge-to-Edge). Google/Apple auf der Registrierung nicht mehr ausgegraut: Tippen ohne Haken markiert die Checkbox rot, lässt sie wackeln und zeigt einen Hinweis. Typecheck + Lint sauber, live im Android-Emulator (Pixel 10 Pro, Gestensteuerung) getestet: Layout, Sprachmenü + Zurück-Geste, Tastatur, Fehlerfall, Hinweis. iOS nur per HTML-Vorschau abgenommen, nicht auf Gerät. Per OTA ausgeliefert 09.10. (Runtime 1.0.6, Android + iOS).
- [x] Wunsch 09.10. (Obsidian „Mobile - UI Rework“): „Freunde einladen“ in den Einstellungen sieht nach AI-Slop aus; Onboarding dynamischer machen statt statischer Seiten. Nachtrag beim Test: Die Detailseite „Freunde einladen“ passte nicht mehr zum neuen Design.
→ **Umgesetzt (09.10.):** Neue `InviteCard` (`src/components/referrals/`) in den Einstellungen und oben auf `/invite`: Rang mit Abzeichen (ohne Rang gestrichelt „Dein erster Rang wartet“), markierbarer Code (Kopieren-Knopf bräuchte natives Modul), animierter Fortschrittsbalken, „Link teilen“. `/invite` neu: Ränge als Zeitleiste mit „Als Nächstes“, Status-Etiketten bei eingeladenen Tieren. Onboarding (`app/onboarding/pet.tsx` + `src/components/onboarding/flow/`): Willkommen mit 3 Regel-Karten + beiden Bestätigungen auf einer Seite → Name (Buchstabe live) → Tierart als Chips + Rasse → Beschreibung/Geschichte (legt Tier an) → Foto → Spinnen-Schalter (entfällt bei Spinne) → Herkunft (nur erstes Tier) → Abschluss mit Pfoten-Konfetti; Fortschrittsbalken, Zurück (nicht nach dem Anlegen), Android-Zurück-Geste geht einen Schritt zurück. Fund: Neue Nutzer sahen auf „Bevor es losgeht“ alle 7 Änderungsnotizen der Rechtstexte statt der Regeln — Regeln jetzt als feste App-Texte, `legal.acknowledge` unverändert (Web-Onboarding hat dasselbe Problem noch). Typecheck + Lint sauber, live im Android-Emulator (Dunkelmodus) getestet: Karte, `/invite`, kompletter Onboarding-Durchlauf mit Testtier „Testi“ (danach über „Tier löschen“ entfernt, DB geprüft). Einmal reagierte „Profil anlegen“ erst beim zweiten Tippen, nicht reproduziert. Hellmodus und iOS nicht auf Gerät geprüft.
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Animated, Easing, type StyleProp, type ViewStyle } from "react-native";
import { useReducedMotion } from "../../hooks/useReducedMotion";
import { useReducedMotion } from "../hooks/useReducedMotion";
const START_DELAY_MS = 250;
const STAGGER_MS = 60;
+1 -1
View File
@@ -7,7 +7,7 @@ import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens } from "../../theme/tokens";
import { AuthLogo } from "./AuthLogo";
import { LanguageChip } from "./LanguageChip";
import { Reveal } from "./Reveal";
import { Reveal } from "../Reveal";
const GLOW_HEIGHT = 420;
/** Keeps tappable content out of Android's edge back-gesture zone. */
@@ -8,6 +8,7 @@ import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
import { StepScaffold } from "./flow/StepScaffold";
const SOURCES = [
"FRIENDS",
@@ -46,9 +47,16 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
}
return (
<View style={styles.container}>
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
<StepScaffold
title={t("referrals.onboardingTitle")}
subtitle={t("referrals.onboardingSubtitle")}
footer={
<>
<Button title={t("common.continue")} isLoading={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
<Button title={t("common.skip")} variant="outline" onPress={onDone} style={styles.skip} />
</>
}
>
<View style={styles.chips} accessibilityRole="radiogroup">
{SOURCES.map((option) => {
@@ -82,18 +90,13 @@ export function AcquisitionStep({ canRedeem, onDone }: Props) {
</View>
)}
<Button title={saveSource.isPending ? "…" : t("common.continue")} disabled={saveSource.isPending} onPress={handleContinue} style={styles.cta} />
<Button title={t("common.skip")} variant="outline" onPress={onDone} />
</View>
</StepScaffold>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 8 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 12 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
chip: { paddingHorizontal: 14, paddingVertical: 9, borderRadius: 999, borderWidth: 1, borderColor: colors.hairlineGray },
chipSelected: { borderColor: colors.marigoldOrange, backgroundColor: colors.marigoldTint },
@@ -102,6 +105,7 @@ function makeStyles(colors: ColorTokens) {
redeemBox: { padding: 14, borderRadius: radii.xl, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 12 },
redeemedRow: { flexDirection: "row", alignItems: "center", gap: 8 },
redeemedText: { ...typography.body, flex: 1 },
cta: { marginTop: 4 },
cta: { height: 54, borderRadius: 15 },
skip: { height: 48, borderRadius: 15, marginTop: 6 },
});
}
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { View, Text, Pressable, StyleSheet, Switch } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { Bug, Check } from "lucide-react-native";
import { EyeOff } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { Button } from "../Button";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { fontFamily, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { StepScaffold } from "./flow/StepScaffold";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
@@ -30,61 +31,62 @@ export function ArachnophobiaOptIn({ onContinue }: Props) {
);
return (
<View style={styles.container}>
<View style={styles.titleRow}>
<Bug color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>{t("onboarding.arachnophobiaTitle")}</Text>
</View>
<Text style={styles.subtitle}>{t("onboarding.arachnophobiaSubtitle")}</Text>
<Pressable style={styles.checkboxRow} onPress={() => setHideSpiders((v) => !v)}>
<View style={[styles.checkbox, hideSpiders && styles.checkboxChecked]}>
{hideSpiders && <Check color={colors.pureWhite} size={14} />}
<StepScaffold
title={t("onboarding.spiderTitle")}
subtitle={t("onboarding.spiderSubtitle")}
footer={
<Button
title={t("common.continue")}
isLoading={setPreferenceMutation.isPending}
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
style={styles.cta}
/>
}
>
<Pressable
style={styles.card}
onPress={() => setHideSpiders((v) => !v)}
accessibilityRole="switch"
accessibilityState={{ checked: hideSpiders }}
>
<View style={styles.icon}>
<EyeOff color={colors.marigoldDeep} size={20} />
</View>
<Text style={styles.checkboxLabel}>{t("onboarding.arachnophobiaCheckboxLabel")}</Text>
<View style={styles.cardText}>
<Text style={styles.cardTitle}>{t("onboarding.spiderToggle")}</Text>
<Text style={styles.cardSub}>{t("onboarding.spiderWhere")}</Text>
</View>
<Switch
value={hideSpiders}
onValueChange={setHideSpiders}
trackColor={{ false: colors.hairlineGray, true: colors.marigoldOrange }}
thumbColor="#ffffff"
ios_backgroundColor={colors.hairlineGray}
/>
</Pressable>
<Text style={styles.hint}>{t("onboarding.arachnophobiaHint")}</Text>
<Button
title={setPreferenceMutation.isPending ? "…" : t("common.continue")}
disabled={setPreferenceMutation.isPending}
onPress={() => setPreferenceMutation.mutate({ hideSpiders })}
style={styles.cta}
/>
</View>
</StepScaffold>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 4 },
titleRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 },
title: { ...typography.title, fontSize: 18 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
checkboxRow: {
card: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
alignItems: "center",
gap: 14,
padding: 16,
borderRadius: 18,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 16,
backgroundColor: colors.pureWhite,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 5,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
alignItems: "center",
justifyContent: "center",
marginTop: 2,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 },
hint: { ...typography.caption, marginBottom: 16 },
cta: { marginTop: 4 },
icon: { width: 44, height: 44, borderRadius: 13, backgroundColor: colors.marigoldTint, alignItems: "center", justifyContent: "center" },
cardText: { flex: 1 },
cardTitle: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
cardSub: { ...typography.body, fontSize: 13, color: colors.quietGray },
hint: { ...typography.body, fontSize: 13, color: colors.quietGray, marginTop: 4 },
cta: { height: 54, borderRadius: 15 },
});
}
+58 -37
View File
@@ -6,9 +6,9 @@ import { useMutation } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { uploadPickedImage } from "../../lib/upload";
import { PetAvatar } from "../PetAvatar";
import { Button } from "../Button";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { fontFamily, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { StepScaffold } from "./flow/StepScaffold";
import { useTheme } from "../../context/ThemeContext";
type Props = {
@@ -72,74 +72,95 @@ export function AvatarStep({ petId, petName, onDone }: Props) {
const isUploading = getPresignedUrlMutation.isPending || confirmUploadMutation.isPending;
return (
<View style={styles.container}>
<Text style={styles.title}>{t("onboarding.avatarTitle")}</Text>
<Text style={styles.subtitle}>{t("onboarding.avatarSubtitle")}</Text>
{asset ? (
<View style={styles.previewWrap}>
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
{isUploading && (
<View style={styles.previewOverlay}>
<ActivityIndicator color={colors.pureWhite} />
</View>
<StepScaffold
title={t("onboarding.photoTitle", { name: petName })}
highlight={petName}
subtitle={t("onboarding.photoSubtitle", { name: petName })}
footer={
// Ohne „Überspringen“-Link (Apple 5.1.1(iv)): „Weiter“ führt auch ohne Foto weiter.
<Button title={t("common.continue")} onPress={onDone} disabled={isUploading} style={styles.cta} />
}
>
<View style={styles.photoRing}>
<View style={styles.photo}>
{asset ? (
<>
<RNImage source={{ uri: asset.uri }} style={styles.preview} />
{isUploading && (
<View style={styles.previewOverlay}>
<ActivityIndicator color="#ffffff" />
</View>
)}
</>
) : (
<Text style={styles.letter}>{petName.trim().charAt(0).toUpperCase()}</Text>
)}
</View>
) : (
<PetAvatar name={petName} avatarKey={null} size="lg" />
)}
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery} disabled={isUploading}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery} disabled={isUploading} android_ripple={{ color: colors.marigoldTint }}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto} disabled={isUploading}>
<Pressable style={styles.pickerButton} onPress={takePhoto} disabled={isUploading} android_ripple={{ color: colors.marigoldTint }}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
</Pressable>
</View>
{/* Ohne „Überspringen“-Link (Apple 5.1.1(iv)): „Fertig“ führt auch ohne Foto weiter. */}
<Button title={t("common.done")} onPress={onDone} disabled={isUploading} style={styles.cta} />
</View>
</StepScaffold>
);
}
const PHOTO_SIZE = 156;
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 4, alignItems: "center" },
title: { ...typography.title, fontSize: 18, textAlign: "center" },
subtitle: { ...typography.body, color: colors.quietGray, textAlign: "center", marginBottom: 20 },
previewWrap: { width: 96, height: 96, borderRadius: 48, overflow: "hidden", backgroundColor: colors.hairlineGray },
photoRing: {
alignSelf: "center",
padding: 8,
borderRadius: PHOTO_SIZE,
borderWidth: 2,
borderStyle: "dashed",
borderColor: colors.marigoldOrange,
marginTop: 6,
},
photo: {
width: PHOTO_SIZE,
height: PHOTO_SIZE,
borderRadius: PHOTO_SIZE / 2,
overflow: "hidden",
backgroundColor: colors.marigoldOrange,
alignItems: "center",
justifyContent: "center",
},
letter: { fontFamily: fontFamily.extraBold, fontSize: 60, color: "#ffffff" },
preview: { width: "100%", height: "100%" },
previewOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.4)",
alignItems: "center",
justifyContent: "center",
},
error: { ...typography.body, color: colors.alertRed, marginTop: 12, textAlign: "center" },
pickerRow: { flexDirection: "row", gap: 12, marginTop: 20, alignSelf: "stretch" },
pickerRow: { flexDirection: "row", gap: 10, marginTop: 24 },
pickerButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 48,
borderRadius: radii.lg,
borderWidth: 1,
height: 56,
borderRadius: 16,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
overflow: "hidden",
},
pickerText: { ...typography.body, color: colors.ink },
cta: { marginTop: 20, alignSelf: "stretch" },
pickerText: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
cta: { height: 54, borderRadius: 15 },
});
}
@@ -0,0 +1,85 @@
import { useMemo } from "react";
import { StyleSheet, Text, TextInput } from "react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../../context/ThemeContext";
import { Button } from "../../Button";
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
import { StepScaffold } from "./StepScaffold";
const BIO_MAX = 150;
const STORY_MAX = 500;
type Props = {
petName: string;
bio: string;
adoptionStory: string;
onChangeBio: (value: string) => void;
onChangeAdoptionStory: (value: string) => void;
error: string | null;
isCreating: boolean;
/** Creates the pet — both texts are optional. */
onCreate: () => void;
};
/** Step "Erzähl uns von {name}" — optional bio + adoption story, then the pet is created. */
export function AboutStep({ petName, bio, adoptionStory, onChangeBio, onChangeAdoptionStory, error, isCreating, onCreate }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<StepScaffold
title={t("onboarding.aboutTitle", { name: petName })}
highlight={petName}
subtitle={t("onboarding.aboutSubtitle")}
footer={
<>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button title={t("onboarding.aboutCreate")} isLoading={isCreating} onPress={onCreate} style={styles.cta} />
</>
}
>
<TextInput
style={[styles.area, styles.areaTall]}
value={bio}
onChangeText={onChangeBio}
placeholder={t("petForm.bioPlaceholder")}
placeholderTextColor={colors.quietGray}
maxLength={BIO_MAX}
multiline
/>
<TextInput
style={styles.area}
value={adoptionStory}
onChangeText={onChangeAdoptionStory}
placeholder={t("onboarding.storyPlaceholder", { name: petName })}
placeholderTextColor={colors.quietGray}
maxLength={STORY_MAX}
multiline
/>
</StepScaffold>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
area: {
minHeight: 72,
borderRadius: 16,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
paddingHorizontal: 14,
paddingTop: 12,
paddingBottom: 12,
fontFamily: fontFamily.regular,
fontSize: 15,
color: colors.ink,
textAlignVertical: "top",
},
areaTall: { minHeight: 104 },
error: { ...typography.body, color: colors.alertRed, marginBottom: 6 },
cta: { height: 54, borderRadius: 15 },
});
}
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from "react";
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { PawPrint } from "lucide-react-native";
import { useTheme } from "../../../context/ThemeContext";
import { useReducedMotion } from "../../../hooks/useReducedMotion";
import { Button } from "../../Button";
import { Reveal } from "../../Reveal";
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
const PAW_COUNT = 16;
const BURST_MS = 1300;
type Props = { petName: string; onFinish: () => void };
/** Closing moment after onboarding: the pet's badge pops in, paws burst around it. */
export function DoneStep({ petName, onFinish }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const isReducedMotion = useReducedMotion();
const [pop] = useState(() => new Animated.Value(isReducedMotion ? 1 : 0));
useEffect(() => {
if (isReducedMotion) {
pop.setValue(1);
return;
}
const animation = Animated.spring(pop, { toValue: 1, friction: 5, tension: 70, useNativeDriver: true });
animation.start();
return () => animation.stop();
}, [isReducedMotion, pop]);
const letter = petName.trim().charAt(0).toUpperCase();
const scale = pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] });
return (
<View style={styles.container}>
<View style={styles.center}>
<View>
{isReducedMotion ? null : <PawBurst color={colors.marigoldOrange} />}
<Animated.View style={[styles.badge, { opacity: pop, transform: [{ scale }] }]}>
<Text style={styles.letter}>{letter}</Text>
</Animated.View>
</View>
<Reveal index={2}>
<Text style={styles.title}>{t("onboarding.doneTitle", { name: petName })}</Text>
</Reveal>
<Reveal index={3}>
<Text style={styles.subtitle}>{t("onboarding.doneSubtitle")}</Text>
</Reveal>
</View>
<Button title={t("onboarding.doneCta")} onPress={onFinish} style={styles.cta} />
</View>
);
}
function PawBurst({ color }: { color: string }) {
const [progress] = useState(() => new Animated.Value(0));
useEffect(() => {
const animation = Animated.timing(progress, {
toValue: 1,
duration: BURST_MS,
easing: Easing.bezier(0.15, 0.7, 0.3, 1),
useNativeDriver: true,
});
animation.start();
return () => animation.stop();
}, [progress]);
return (
<View style={burstStyles.layer} pointerEvents="none">
{Array.from({ length: PAW_COUNT }, (_, i) => {
const angle = (i / PAW_COUNT) * Math.PI * 2;
const distance = 110 + (i % 3) * 36;
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [0, Math.cos(angle) * distance] });
const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [0, Math.sin(angle) * distance - 40] });
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", `${(i % 2 ? 1 : -1) * (90 + i * 12)}deg`] });
const opacity = progress.interpolate({ inputRange: [0, 0.15, 1], outputRange: [0, 1, 0] });
return (
<Animated.View key={i} style={[burstStyles.paw, { opacity, transform: [{ translateX }, { translateY }, { rotate }] }]}>
<PawPrint color={color} fill={color} size={18} />
</Animated.View>
);
})}
</View>
);
}
const burstStyles = StyleSheet.create({
layer: { ...StyleSheet.absoluteFill, alignItems: "center", justifyContent: "center" },
paw: { position: "absolute" },
});
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { flex: 1, paddingHorizontal: 26, paddingBottom: 16 },
center: { flex: 1, alignItems: "center", justifyContent: "center" },
badge: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: colors.marigoldOrange,
alignItems: "center",
justifyContent: "center",
borderWidth: 8,
borderColor: colors.marigoldTint,
},
letter: { fontFamily: fontFamily.extraBold, fontSize: 48, color: "#ffffff" },
title: {
fontFamily: fontFamily.extraBold,
fontSize: 28,
lineHeight: 34,
letterSpacing: -0.6,
color: colors.ink,
textAlign: "center",
marginTop: 26,
},
subtitle: { ...typography.body, fontSize: 15, color: colors.quietGray, textAlign: "center", marginTop: 8 },
cta: { height: 54, borderRadius: 15 },
});
}
@@ -0,0 +1,119 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Animated, Pressable, StyleSheet, Text, TextInput } from "react-native";
import { useTranslation } from "react-i18next";
import { useTheme } from "../../../context/ThemeContext";
import { useReducedMotion } from "../../../hooks/useReducedMotion";
import { Button } from "../../Button";
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
import { StepScaffold } from "./StepScaffold";
const NAME_MAX = 30;
type Props = {
name: string;
nickname: string;
onChangeName: (value: string) => void;
onChangeNickname: (value: string) => void;
onNext: () => void;
/** "Ich betreue ein Tier mit" — the co-caretaker path instead of creating a pet. */
onCaretaker: () => void;
};
/** Step "Wie heißt dein Tier?" — the avatar circle shows the first letter live. */
export function NameStep({ name, nickname, onChangeName, onChangeNickname, onNext, onCaretaker }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [isNicknameOpen, setIsNicknameOpen] = useState(nickname.length > 0);
const letter = name.trim().charAt(0).toUpperCase() || "?";
const canContinue = name.trim().length > 0;
return (
<StepScaffold
title={t("onboarding.nameTitle")}
subtitle={t("onboarding.nameSubtitle")}
footer={
<>
<Button title={t("common.continue")} disabled={!canContinue} onPress={onNext} style={styles.cta} />
<Pressable onPress={onCaretaker} hitSlop={8} style={styles.caretaker} accessibilityRole="button">
<Text style={styles.caretakerText}>{t("caretaker.onboardingSwitch")}</Text>
</Pressable>
</>
}
>
<LetterAvatar letter={letter} colors={colors} />
<TextInput
style={styles.input}
value={name}
onChangeText={onChangeName}
placeholder={t("petForm.namePlaceholder")}
placeholderTextColor={colors.quietGray}
maxLength={NAME_MAX}
autoFocus
returnKeyType="next"
onSubmitEditing={() => canContinue && onNext()}
/>
{isNicknameOpen ? (
<TextInput
style={[styles.input, styles.inputSmall]}
value={nickname}
onChangeText={onChangeNickname}
placeholder={t("petForm.nicknamePlaceholder")}
placeholderTextColor={colors.quietGray}
maxLength={NAME_MAX}
/>
) : (
<Pressable onPress={() => setIsNicknameOpen(true)} hitSlop={8} accessibilityRole="button">
<Text style={styles.addLink}>{t("onboarding.addNickname")}</Text>
</Pressable>
)}
</StepScaffold>
);
}
function LetterAvatar({ letter, colors }: { letter: string; colors: ColorTokens }) {
const isReducedMotion = useReducedMotion();
const [bump] = useState(() => new Animated.Value(1));
const previous = useRef(letter);
useEffect(() => {
if (previous.current === letter) return;
previous.current = letter;
if (isReducedMotion) return;
bump.setValue(1.1);
Animated.spring(bump, { toValue: 1, friction: 4, tension: 160, useNativeDriver: true }).start();
}, [letter, isReducedMotion, bump]);
return (
<Animated.View style={[avatarStyles.circle, { backgroundColor: colors.marigoldOrange, transform: [{ scale: bump }] }]}>
<Text style={avatarStyles.letter}>{letter}</Text>
</Animated.View>
);
}
const avatarStyles = StyleSheet.create({
circle: { width: 104, height: 104, borderRadius: 52, alignSelf: "center", alignItems: "center", justifyContent: "center", marginBottom: 16 },
letter: { fontFamily: fontFamily.extraBold, fontSize: 44, color: "#ffffff" },
});
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
input: {
height: 58,
borderRadius: 16,
borderWidth: 1.5,
borderColor: colors.marigoldOrange,
backgroundColor: colors.pureWhite,
paddingHorizontal: 16,
fontFamily: fontFamily.semiBold,
fontSize: 20,
color: colors.ink,
},
inputSmall: { height: 50, fontSize: 16, borderColor: colors.hairlineGray },
addLink: { ...typography.body, fontFamily: fontFamily.semiBold, color: colors.marigoldDeep, marginTop: 2 },
cta: { height: 54, borderRadius: 15 },
caretaker: { alignSelf: "center", paddingVertical: 12 },
caretakerText: { ...typography.body, fontFamily: fontFamily.semiBold, color: colors.quietGray },
});
}
@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { useTranslation } from "react-i18next";
import { ChevronLeft, X } from "lucide-react-native";
import { useTheme } from "../../../context/ThemeContext";
import { useReducedMotion } from "../../../hooks/useReducedMotion";
import type { ColorTokens } from "../../../theme/tokens";
const FILL_MS = 450;
type Props = {
/** Number of progress segments. */
count: number;
/** How many segments are filled (current step included). */
filled: number;
onBack?: () => void;
onClose: () => void;
};
/** Back arrow, segmented progress and close button above the onboarding steps. */
export function OnboardingTopBar({ count, filled, onBack, onClose }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<View style={styles.bar}>
<Pressable
onPress={onBack}
disabled={!onBack}
hitSlop={10}
style={[styles.iconButton, !onBack && styles.hidden]}
accessibilityRole="button"
accessibilityLabel={t("onboarding.back")}
>
<ChevronLeft color={colors.ink} size={24} />
</Pressable>
<View style={styles.segments} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: count, now: filled }}>
{Array.from({ length: count }, (_, i) => (
<Segment key={i} isFilled={i < filled} colors={colors} />
))}
</View>
<Pressable
onPress={onClose}
hitSlop={10}
style={styles.iconButton}
accessibilityRole="button"
accessibilityLabel={t("onboarding.close")}
>
<X color={colors.quietGray} size={22} />
</Pressable>
</View>
);
}
function Segment({ isFilled, colors }: { isFilled: boolean; colors: ColorTokens }) {
const isReducedMotion = useReducedMotion();
const [fill] = useState(() => new Animated.Value(isFilled ? 1 : 0));
useEffect(() => {
if (isReducedMotion) {
fill.setValue(isFilled ? 1 : 0);
return;
}
// width can't run on the native driver.
const animation = Animated.timing(fill, { toValue: isFilled ? 1 : 0, duration: FILL_MS, useNativeDriver: false });
animation.start();
return () => animation.stop();
}, [isFilled, isReducedMotion, fill]);
const width = fill.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"] });
return (
<View style={[segmentStyles.track, { backgroundColor: colors.hairlineGray }]}>
<Animated.View style={[segmentStyles.fill, { width, backgroundColor: colors.marigoldOrange }]} />
</View>
);
}
const segmentStyles = StyleSheet.create({
track: { flex: 1, height: 5, borderRadius: 3, overflow: "hidden" },
fill: { height: 5, borderRadius: 3 },
});
function makeStyles(colors: ColorTokens) {
return StyleSheet.create({
bar: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, height: 48, backgroundColor: colors.porchWhite },
iconButton: { width: 36, height: 36, alignItems: "center", justifyContent: "center" },
hidden: { opacity: 0 },
segments: { flex: 1, flexDirection: "row", gap: 5 },
});
}
@@ -0,0 +1,89 @@
import { useMemo } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../api/trpc";
import { useTheme } from "../../../context/ThemeContext";
import { Button } from "../../Button";
import { BreedPicker } from "../../pet/BreedPicker";
import { Reveal } from "../../Reveal";
import type { PickerOption } from "../../OptionPicker";
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
import { StepScaffold } from "./StepScaffold";
type Props = {
petName: string;
speciesId: string | null;
breedName: string | null;
onSelectSpecies: (option: PickerOption) => void;
onSelectBreed: (option: PickerOption) => void;
onNext: () => void;
};
/** Step "Was für ein Tier ist {name}?" — species as chips (data-driven), breed appears once a species is picked. */
export function SpeciesStep({ petName, speciesId, breedName, onSelectSpecies, onSelectBreed, onNext }: Props) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
const sorted = useMemo(() => [...species].sort((a, b) => a.name.localeCompare(b.name, i18n.language)), [species, i18n.language]);
return (
<StepScaffold
title={t("onboarding.speciesTitle", { name: petName })}
highlight={petName}
subtitle={t("onboarding.speciesSubtitle")}
footer={<Button title={t("common.continue")} disabled={!speciesId} onPress={onNext} style={styles.cta} />}
>
{isLoading ? (
<ActivityIndicator color={colors.marigoldOrange} />
) : (
<View style={styles.chips} accessibilityRole="radiogroup">
{sorted.map((option) => {
const isSelected = option.id === speciesId;
return (
<Pressable
key={option.id}
onPress={() => onSelectSpecies(option)}
style={({ pressed }) => [styles.chip, isSelected && styles.chipSelected, pressed && styles.chipPressed]}
accessibilityRole="radio"
accessibilityState={{ checked: isSelected }}
>
<Text style={[styles.chipText, isSelected && styles.chipTextSelected]}>{option.name}</Text>
</Pressable>
);
})}
</View>
)}
{speciesId ? (
<Reveal index={0} style={styles.breed}>
<Text style={styles.breedLabel}>{t("onboarding.breedOptional")}</Text>
<BreedPicker speciesId={speciesId} selectedName={breedName} onSelect={onSelectBreed} />
</Reveal>
) : null}
</StepScaffold>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 999,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
},
chipSelected: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
chipPressed: { transform: [{ scale: 0.94 }] },
chipText: { fontFamily: fontFamily.semiBold, fontSize: 14, color: colors.ink },
chipTextSelected: { color: "#ffffff" },
breed: { marginTop: 10, gap: 6 },
breedLabel: { ...typography.caption, color: colors.quietGray },
cta: { height: 54, borderRadius: 15 },
});
}
@@ -0,0 +1,53 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { useTheme } from "../../../context/ThemeContext";
import { fontFamily, getTypography, type ColorTokens } from "../../../theme/tokens";
type Props = {
title: string;
/** Part of the title shown in marigold (the pet's name, "PawFeed"). */
highlight?: string;
subtitle?: string;
children?: ReactNode;
/** Pinned below the content: the step's main button(s). */
footer: ReactNode;
};
/** Shared layout of every onboarding step: big title, subtitle, content, buttons at the bottom. */
export function StepScaffold({ title, highlight, subtitle, children, footer }: Props) {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>{renderHighlighted(title, highlight, styles.highlight)}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
<View style={styles.content}>{children}</View>
<View style={styles.footer}>{footer}</View>
</ScrollView>
);
}
function renderHighlighted(title: string, highlight: string | undefined, style: object): ReactNode {
const at = highlight ? title.indexOf(highlight) : -1;
if (!highlight || at < 0) return title;
return (
<>
{title.slice(0, at)}
<Text style={style}>{highlight}</Text>
{title.slice(at + highlight.length)}
</>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { flexGrow: 1, paddingHorizontal: 26, paddingTop: 18, paddingBottom: 16 },
title: { fontFamily: fontFamily.extraBold, fontSize: 28, lineHeight: 34, letterSpacing: -0.6, color: colors.ink },
highlight: { color: colors.marigoldOrange },
subtitle: { ...typography.body, fontSize: 15, color: colors.quietGray, marginTop: 8 },
content: { flexGrow: 1, marginTop: 22, gap: 10 },
footer: { marginTop: 20, gap: 4 },
});
}
@@ -0,0 +1,41 @@
import { useEffect, useState, type ReactNode } from "react";
import { Animated, Easing, StyleSheet } from "react-native";
import { useReducedMotion } from "../../../hooks/useReducedMotion";
const SLIDE_PX = 48;
const DURATION_MS = 380;
type Props = {
/** 1 = came from the previous step (enter from the right), -1 = went back. */
direction: 1 | -1;
children: ReactNode;
};
/**
* Slides a step in on mount. The parent keys it by step, so each step change
* mounts a fresh StepSlide and plays the entrance once.
*/
export function StepSlide({ direction, children }: Props) {
const isReducedMotion = useReducedMotion();
const [progress] = useState(() => new Animated.Value(0));
useEffect(() => {
if (isReducedMotion) {
progress.setValue(1);
return;
}
const animation = Animated.timing(progress, {
toValue: 1,
duration: DURATION_MS,
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
useNativeDriver: true,
});
animation.start();
return () => animation.stop();
}, [isReducedMotion, progress]);
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [SLIDE_PX * direction, 0] });
return <Animated.View style={[styles.fill, { opacity: progress, transform: [{ translateX }] }]}>{children}</Animated.View>;
}
const styles = StyleSheet.create({ fill: { flex: 1 } });
@@ -0,0 +1,183 @@
import { useMemo, useState } from "react";
import { Linking, Pressable, StyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { Check, Heart, PawPrint, ShieldCheck, type LucideIcon } from "lucide-react-native";
import { useTRPC } from "../../../api/trpc";
import { env } from "../../../lib/env";
import { useTheme } from "../../../context/ThemeContext";
import { Button } from "../../Button";
import { Reveal } from "../../Reveal";
import { getTypography, type ColorTokens } from "../../../theme/tokens";
import { StepScaffold } from "./StepScaffold";
const RULES: { icon: LucideIcon; key: "rule1" | "rule2" | "rule3" }[] = [
{ icon: Heart, key: "rule1" },
{ icon: PawPrint, key: "rule2" },
{ icon: ShieldCheck, key: "rule3" },
];
type Props = {
/** Terms/privacy have versions this owner hasn't acknowledged yet. */
needsLegal: boolean;
/** The two first-pet declarations (content rights, data processing) are missing. */
needsConsent: boolean;
onContinue: () => void;
};
/**
* Replaces the former OnboardingGuidelines + UploadConsent screens. The
* rules are three fixed cards (app i18n): legal.getPendingUpdate returns the
* whole changelog for a new owner, which read as a wall of "we updated our
* privacy policy" notes instead of community rules. Acknowledging still goes
* through legal.acknowledge; both declarations stay separate, explicit
* checkboxes with their full text (Gitea #3).
*/
export function WelcomeStep({ needsLegal, needsConsent, onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const [hasContentRights, setHasContentRights] = useState(false);
const [hasDataConsent, setHasDataConsent] = useState(false);
const [error, setError] = useState<string | null>(null);
const acknowledgeLegal = useMutation(trpc.legal.acknowledge.mutationOptions());
const acknowledgeConsent = useMutation(trpc.legal.acknowledgeUploadConsent.mutationOptions());
const isSaving = acknowledgeLegal.isPending || acknowledgeConsent.isPending;
const canContinue = !needsConsent || (hasContentRights && hasDataConsent);
const handleContinue = async () => {
setError(null);
try {
if (needsLegal) await acknowledgeLegal.mutateAsync();
if (needsConsent) await acknowledgeConsent.mutateAsync();
// Cache is refreshed when the onboarding is left (pet.tsx), not here — see there why.
onContinue();
} catch {
setError(t("onboarding.saveError"));
}
};
return (
<StepScaffold
title={t("onboarding.welcomeTitle")}
highlight="PawFeed"
subtitle={t("onboarding.welcomeSubtitle")}
footer={
<>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={needsConsent ? t("onboarding.welcomeContinueConsent") : t("onboarding.welcomeContinue")}
disabled={!canContinue}
isLoading={isSaving}
onPress={handleContinue}
style={styles.cta}
/>
</>
}
>
{RULES.map(({ icon: Icon, key }, i) => (
<Reveal key={key} index={i}>
<View style={styles.rule}>
<View style={styles.ruleIcon}>
<Icon color={colors.marigoldDeep} size={18} />
</View>
<View style={styles.ruleText}>
<Text style={styles.ruleTitle}>{t(`onboarding.${key}Title`)}</Text>
<Text style={styles.ruleBody}>{t(`onboarding.${key}Body`)}</Text>
</View>
</View>
</Reveal>
))}
<View style={styles.links}>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)} hitSlop={8}>
<Text style={styles.link}>{t("onboarding.termsShort")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)} hitSlop={8}>
<Text style={styles.link}>{t("onboarding.privacyShort")}</Text>
</Pressable>
</View>
{needsConsent ? (
<View style={styles.consents}>
<ConsentCheck
isChecked={hasContentRights}
onToggle={() => setHasContentRights((v) => !v)}
label={t("onboarding.contentRightsLabel")}
/>
<ConsentCheck
isChecked={hasDataConsent}
onToggle={() => setHasDataConsent((v) => !v)}
label={t("onboarding.dataProcessingLabel")}
/>
</View>
) : null}
</StepScaffold>
);
}
function ConsentCheck({ isChecked, onToggle, label }: { isChecked: boolean; onToggle: () => void; label: string }) {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable
style={styles.checkRow}
onPress={onToggle}
hitSlop={4}
accessibilityRole="checkbox"
accessibilityState={{ checked: isChecked }}
>
<View style={[styles.checkbox, isChecked && styles.checkboxChecked]}>
{isChecked ? <Check color="#ffffff" size={14} strokeWidth={3} /> : null}
</View>
<Text style={styles.checkLabel}>{label}</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
rule: {
flexDirection: "row",
gap: 12,
padding: 12,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
},
ruleIcon: {
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.marigoldTint,
alignItems: "center",
justifyContent: "center",
},
ruleText: { flex: 1 },
ruleTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 14.5 },
ruleBody: { ...typography.body, fontSize: 13, lineHeight: 18, color: colors.quietGray },
links: { flexDirection: "row", flexWrap: "wrap", gap: 16, marginTop: 2 },
link: { ...typography.caption, color: colors.marigoldDeep },
consents: { gap: 12, marginTop: 8 },
checkRow: { flexDirection: "row", gap: 10 },
checkbox: {
width: 22,
height: 22,
borderRadius: 7,
borderWidth: 1.5,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
alignItems: "center",
justifyContent: "center",
marginTop: 1,
},
checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
checkLabel: { ...typography.body, flex: 1, fontSize: 12.5, lineHeight: 17 },
error: { ...typography.body, color: colors.alertRed, marginBottom: 6 },
cta: { height: 54, borderRadius: 15 },
});
}
+223 -16
View File
@@ -1,35 +1,242 @@
import { useMemo } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useEffect, useMemo, useState } from "react";
import { Animated, Easing, Pressable, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { ChevronRight, Gift } from "lucide-react-native";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, PawPrint, Share2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens } from "../../theme/tokens";
import { useReducedMotion } from "../../hooks/useReducedMotion";
import { useReferralRanks, type ReferralRankView } from "../../hooks/useReferralRanks";
import { ReferralIcon } from "../../lib/referral-icons";
import { shareReferralLink } from "../../lib/referral-share";
import { Reveal } from "../Reveal";
import { fontFamily, getTypography, type ColorTokens } from "../../theme/tokens";
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
export function InviteFriendsSection() {
const FILL_DELAY_MS = 350;
const FILL_MS = 900;
const BREATHE_MS = 1200;
type Props = {
/** Settings shows a "Ränge" button to the detail page; the detail page itself doesn't. */
showRanksLink?: boolean;
};
/**
* Einladungskarte (Einstellungen und Kopf von /invite): aktueller Rang (oder
* der erste, noch offene, gestrichelt), eigener Code, Fortschritt bis zum
* nächsten Rang und Teilen direkt aus der Karte.
*/
export function InviteCard({ showRanksLink = false }: Props) {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { ranks } = useReferralRanks();
const { data } = useQuery(trpc.referrals.me.queryOptions());
if (!data) return <View style={[styles.card, styles.placeholder]} />;
const activeRanks = ranks.filter((rank) => rank.isActive).sort((a, b) => a.threshold - b.threshold);
const achieved = new Set(data.achievedRankIds);
const current = activeRanks.filter((rank) => achieved.has(rank.id)).at(-1) ?? null;
const next = activeRanks.find((rank) => rank.id === data.nextRankId) ?? null;
const shownRank = current ?? next;
const heading = current ? current.name : next ? t("referrals.cardFirstRankWaiting") : t("referrals.title");
const subline = current
? `${t("referrals.cardYourRank")} · ${current.description}`
: next
? `${next.name} · ${next.description}`
: null;
let progressText: string;
if (!next) progressText = t("referrals.allRanksReached");
else if (!current && data.qualifiedCount === 0) progressText = t("referrals.cardFirstInvite", { count: next.threshold });
else progressText = t("referrals.cardProgress", { done: data.qualifiedCount, count: next.threshold, name: next.name });
const progress = next ? Math.min(1, data.qualifiedCount / next.threshold) : 1;
return (
<View>
<Text style={styles.sectionTitle}>{t("referrals.title")}</Text>
<Pressable style={styles.row} onPress={() => router.push("/invite")} accessibilityRole="button">
<Gift size={20} color={colors.marigoldOrange} />
<Text style={styles.label}>{t("referrals.settingsLabel")}</Text>
<ChevronRight size={18} color={colors.quietGray} />
</Pressable>
<Reveal index={0}>
<View style={styles.card}>
<PawPrint style={styles.watermark} color={colors.marigoldOrange} size={130} />
<View style={styles.top}>
{shownRank ? <RankDot rank={shownRank} isPending={!current} /> : null}
<View style={styles.topText}>
<Text style={styles.heading}>{heading}</Text>
{subline ? (
<Text style={styles.subline} numberOfLines={2}>
{subline}
</Text>
) : null}
</View>
</View>
<View style={styles.codeBox}>
<Text style={styles.codeLabel}>{t("referrals.cardCodeLabel")}</Text>
{/* selectable: long-press copy without a clipboard module (would need a native build). */}
<Text style={styles.code} selectable>
{data.code}
</Text>
</View>
<ProgressBar progress={progress} colors={colors} />
<Text style={styles.progressText}>{progressText}</Text>
<View style={styles.actions}>
<Pressable
style={({ pressed }) => [styles.share, pressed && styles.pressed]}
onPress={() => shareReferralLink(data.code, t)}
android_ripple={{ color: "rgba(255,255,255,0.25)" }}
accessibilityRole="button"
>
<Share2 size={18} color="#ffffff" />
<Text style={styles.shareText}>{t("referrals.share")}</Text>
</Pressable>
{showRanksLink ? (
<Pressable
style={({ pressed }) => [styles.more, pressed && styles.pressed]}
onPress={() => router.push("/invite")}
android_ripple={{ color: colors.marigoldTint }}
accessibilityRole="button"
>
<Text style={styles.moreText}>{t("referrals.cardRanks")}</Text>
<ChevronRight size={16} color={colors.ink} />
</Pressable>
) : null}
</View>
</View>
</Reveal>
);
}
function RankDot({ rank, isPending }: { rank: ReferralRankView; isPending: boolean }) {
const isReducedMotion = useReducedMotion();
const [scale] = useState(() => new Animated.Value(1));
useEffect(() => {
if (!isPending || isReducedMotion) return;
const breathe = Animated.loop(
Animated.sequence([
Animated.timing(scale, { toValue: 1.08, duration: BREATHE_MS, useNativeDriver: true }),
Animated.timing(scale, { toValue: 1, duration: BREATHE_MS, useNativeDriver: true }),
]),
);
breathe.start();
return () => breathe.stop();
}, [isPending, isReducedMotion, scale]);
return (
<Animated.View
style={[
dotStyles.dot,
isPending
? { borderColor: rank.color, borderStyle: "dashed", borderWidth: 2 }
: { backgroundColor: rank.color },
{ transform: [{ scale }] },
]}
>
<ReferralIcon icon={rank.icon} size={20} color={isPending ? rank.color : "#ffffff"} />
</Animated.View>
);
}
function ProgressBar({ progress, colors }: { progress: number; colors: ColorTokens }) {
const isReducedMotion = useReducedMotion();
const [fill] = useState(() => new Animated.Value(0));
useEffect(() => {
if (isReducedMotion) {
fill.setValue(progress);
return;
}
// width can't run on the native driver.
const animation = Animated.timing(fill, {
toValue: progress,
duration: FILL_MS,
delay: FILL_DELAY_MS,
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
useNativeDriver: false,
});
animation.start();
return () => animation.stop();
}, [progress, isReducedMotion, fill]);
const width = fill.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"] });
return (
<View style={[barStyles.track, { backgroundColor: colors.whisperGray }]}>
<Animated.View style={[barStyles.fill, { width, backgroundColor: colors.marigoldOrange }]} />
</View>
);
}
const dotStyles = StyleSheet.create({
dot: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
});
const barStyles = StyleSheet.create({
track: { height: 8, borderRadius: 4, overflow: "hidden" },
fill: { height: 8, borderRadius: 4 },
});
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
sectionTitle: { ...typography.caption, marginBottom: 10 },
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 },
label: { ...typography.body, flex: 1 },
card: {
gap: 12,
padding: 18,
borderRadius: 22,
borderWidth: 1,
borderColor: colors.marigoldTint,
backgroundColor: colors.pureWhite,
overflow: "hidden",
},
placeholder: { height: 236 },
watermark: { position: "absolute", right: -26, top: -18, opacity: 0.08, transform: [{ rotate: "18deg" }] },
top: { flexDirection: "row", alignItems: "center", gap: 12 },
topText: { flex: 1 },
heading: { ...typography.title, fontSize: 16, lineHeight: 22 },
subline: { ...typography.body, fontSize: 12.5, lineHeight: 17, color: colors.quietGray },
codeBox: {
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 14,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.marigoldOrange,
backgroundColor: colors.porchWhite,
},
codeLabel: { ...typography.label, fontSize: 10.5, letterSpacing: 1 },
code: { fontFamily: fontFamily.extraBold, fontSize: 20, letterSpacing: 1.6, color: colors.marigoldDeep, marginTop: 2 },
progressText: { ...typography.body, fontSize: 13, color: colors.quietGray, marginTop: -4 },
actions: { flexDirection: "row", gap: 8 },
share: {
flex: 1,
height: 46,
borderRadius: 13,
backgroundColor: colors.marigoldOrange,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
overflow: "hidden",
},
shareText: { fontFamily: fontFamily.bold, fontSize: 15, color: "#ffffff" },
more: {
height: 46,
paddingHorizontal: 14,
borderRadius: 13,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
flexDirection: "row",
alignItems: "center",
gap: 4,
overflow: "hidden",
},
moreText: { fontFamily: fontFamily.semiBold, fontSize: 14, color: colors.ink },
pressed: { transform: [{ scale: 0.97 }] },
});
}
+61 -40
View File
@@ -5,7 +5,9 @@ import { Check } from "lucide-react-native";
import { useTheme } from "../../context/ThemeContext";
import { ReferralIcon } from "../../lib/referral-icons";
import type { ReferralRankView } from "../../hooks/useReferralRanks";
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
import { fontFamily, getTypography, type ColorTokens } from "../../theme/tokens";
const DOT = 38;
type Props = {
ranks: ReferralRankView[];
@@ -14,14 +16,22 @@ type Props = {
nextRankId: string | null;
};
/** Port of web's RankLadder — Fortschritt zum nächsten Rang plus alle aktiven Ränge. */
/** "#rrggbb" + alpha → "#rrggbbaa"; other formats are returned unchanged. */
function withAlpha(hex: string, alpha: number): string {
if (!/^#[0-9a-f]{6}$/i.test(hex)) return hex;
return `${hex}${Math.round(alpha * 255).toString(16).padStart(2, "0")}`;
}
/**
* All active ranks as a timeline: achieved ranks filled with a check, the
* next one highlighted ("Als Nächstes"), the rest as outlines with their
* threshold. Progress itself lives in InviteCard above.
*/
export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const activeRanks = ranks.filter((rank) => rank.isActive);
const next = activeRanks.find((rank) => rank.id === nextRankId) ?? null;
const progress = next ? Math.min(100, Math.round((qualifiedCount / next.threshold) * 100)) : 100;
const activeRanks = ranks.filter((rank) => rank.isActive).sort((a, b) => a.threshold - b.threshold);
return (
<View style={styles.card}>
@@ -29,38 +39,38 @@ export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId
<Text style={styles.heading}>{t("referrals.ranksHeading")}</Text>
<Text style={styles.muted}>{t("referrals.countedInvites", { count: qualifiedCount })}</Text>
</View>
{next ? (
<>
<View style={styles.track} accessibilityRole="progressbar" accessibilityValue={{ min: 0, max: next.threshold, now: qualifiedCount }}>
<View style={[styles.fill, { width: `${progress}%`, backgroundColor: next.color }]} />
</View>
<Text style={styles.muted}>
{t("referrals.nextRank", { count: Math.max(0, next.threshold - qualifiedCount), name: next.name })}
</Text>
</>
) : (
<Text style={styles.muted}>{t("referrals.allRanksReached")}</Text>
)}
<View style={styles.list}>
{activeRanks.map((rank) => {
<View>
{activeRanks.map((rank, i) => {
const isAchieved = achievedRankIds.has(rank.id);
const isNext = rank.id === nextRankId;
const isLast = i === activeRanks.length - 1;
return (
<View
key={rank.id}
style={[styles.row, isAchieved ? { borderColor: rank.color } : styles.rowPending]}
>
<View style={[styles.iconCircle, { borderColor: rank.color, backgroundColor: isAchieved ? rank.color : "transparent" }]}>
<ReferralIcon icon={rank.icon} size={16} color={isAchieved ? "#ffffff" : rank.color} />
<View key={rank.id} style={[styles.row, isNext && { backgroundColor: withAlpha(rank.color, 0.12) }]}>
{isLast ? null : <View style={[styles.connector, isAchieved && { backgroundColor: rank.color }]} />}
<View
style={[
styles.dot,
isAchieved ? { backgroundColor: rank.color } : { borderColor: rank.color, borderWidth: 2 },
!isAchieved && !isNext && styles.dotFaded,
]}
>
<ReferralIcon icon={rank.icon} size={18} color={isAchieved ? "#ffffff" : rank.color} />
</View>
<View style={styles.rowText}>
<Text style={styles.rankName}>{rank.name}</Text>
<Text style={styles.rankDescription}>{rank.description}</Text>
<View style={styles.text}>
<Text style={[styles.name, !isAchieved && !isNext && styles.faded]}>{rank.name}</Text>
<Text style={styles.description}>{rank.description}</Text>
</View>
{isAchieved ? (
<Check size={16} color={colors.marigoldOrange} accessibilityLabel={t("referrals.achieved")} />
<View style={[styles.check, { backgroundColor: withAlpha(rank.color, 0.16) }]}>
<Check size={14} color={rank.color} strokeWidth={3} accessibilityLabel={t("referrals.achieved")} />
</View>
) : (
<Text style={styles.muted}>{t("referrals.threshold", { count: rank.threshold })}</Text>
<View style={[styles.pill, isNext && { backgroundColor: rank.color }]}>
<Text style={[styles.pillText, isNext && styles.pillTextNext]}>
{isNext ? t("referrals.ladderNext") : t("referrals.ladderThreshold", { count: rank.threshold })}
</Text>
</View>
)}
</View>
);
@@ -73,18 +83,29 @@ export function RankLadder({ ranks, achievedRankIds, qualifiedCount, nextRankId
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 10, padding: 16, borderRadius: radii["2xl"], borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
card: { gap: 12, padding: 18, borderRadius: 22, borderWidth: 1, borderColor: colors.hairlineGray, backgroundColor: colors.pureWhite },
headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", gap: 8 },
heading: { ...typography.title, fontSize: 16 },
muted: { ...typography.body, fontSize: 13, color: colors.quietGray },
track: { height: 8, borderRadius: 4, backgroundColor: colors.whisperGray, overflow: "hidden" },
fill: { height: 8, borderRadius: 4 },
list: { gap: 8, marginTop: 6 },
row: { flexDirection: "row", alignItems: "center", gap: 12, padding: 10, borderRadius: radii.xl, borderWidth: 1 },
rowPending: { borderColor: colors.hairlineGray, borderStyle: "dashed" },
iconCircle: { width: 32, height: 32, borderRadius: 16, borderWidth: 2, alignItems: "center", justifyContent: "center" },
rowText: { flex: 1 },
rankName: { ...typography.body, fontWeight: "600" },
rankDescription: { ...typography.body, fontSize: 12, color: colors.quietGray },
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10, paddingHorizontal: 8, borderRadius: 16 },
// Line from this dot to the next one; dot centre = 8 (row padding) + DOT / 2.
connector: {
position: "absolute",
left: 8 + DOT / 2 - 1,
top: 10 + DOT,
bottom: -10,
width: 2,
backgroundColor: colors.hairlineGray,
},
dot: { width: DOT, height: DOT, borderRadius: DOT / 2, alignItems: "center", justifyContent: "center", backgroundColor: colors.pureWhite },
dotFaded: { opacity: 0.55 },
text: { flex: 1 },
name: { fontFamily: fontFamily.semiBold, fontSize: 15, color: colors.ink },
faded: { color: colors.quietGray },
description: { ...typography.body, fontSize: 12.5, lineHeight: 17, color: colors.quietGray },
check: { width: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center" },
pill: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999, backgroundColor: colors.whisperGray },
pillText: { fontFamily: fontFamily.semiBold, fontSize: 12, color: colors.quietGray },
pillTextNext: { color: "#ffffff" },
});
}
@@ -0,0 +1,6 @@
import { InviteCard } from "../referrals/InviteCard";
/** Einstieg zum Empfehlungsprogramm oben in den Einstellungen. */
export function InviteFriendsSection() {
return <InviteCard showRanksLink />;
}
+47 -22
View File
@@ -276,28 +276,47 @@
"consentFirstHint": "Bitte setz zuerst den Haken bei den Nutzungsbedingungen, dann kannst du dich auch so registrieren."
},
"onboarding": {
"addPetTitle": "Tier hinzufügen",
"guidelinesTitle": "Bevor es losgeht",
"guidelinesSubtitle": "Ein kurzer Überblick über unsere Community-Richtlinien.",
"termsLink": "Alle Details in unseren Nutzungsbedingungen",
"privacyLink": "Datenschutzerklärung (inkl. Cookies)",
"guidelinesContinue": "Verstanden, weiter",
"consentTitle": "Zwei kurze Bestätigungen",
"consentSubtitle": "Bevor du dein erstes Tier anlegst, brauchen wir deine ausdrückliche Zustimmung zu Folgendem.",
"contentRightsLabel": "Ich bestätige, dass ich alle Rechte an den von mir hochgeladenen Bildern und Videos besitze und dass diese kein fremdes Urheberrecht verletzen.",
"dataProcessingLabel": "Ich willige ein, dass meine Daten zur Bereitstellung des Dienstes an Clerk (Authentifizierung), Cloudflare (Medienspeicherung), Mux (Videoverarbeitung) und Supabase (Datenbank) übermittelt werden.",
"consentPrivacyLink": "Details zu den eingesetzten Dienstleistern in unserer Datenschutzerklärung",
"consentContinue": "Bestätigen und weiter",
"arachnophobiaTitle": "Arachnophobie-Helfer",
"arachnophobiaSubtitle": "PawFeed ist jetzt auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, blenden wir sie für dich komplett aus — im Feed, bei Explore und in der Suche.",
"arachnophobiaCheckboxLabel": "Ja, bitte Spinnen-Inhalte für mich ausblenden.",
"arachnophobiaHint": "Du kannst das jederzeit unter \"Meine Tiere\" wieder ändern.",
"avatarTitle": "Profilfoto hinzufügen",
"avatarSubtitle": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"galleryButton": "Galerie",
"cameraButton": "Kamera",
"uploadError": "Upload fehlgeschlagen — bitte erneut versuchen.",
"cameraPermissionDenied": "Zugriff auf die Kamera wurde nicht erlaubt."
"cameraPermissionDenied": "Zugriff auf die Kamera wurde nicht erlaubt.",
"back": "Zurück",
"close": "Schließen",
"saveError": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
"welcomeTitle": "Willkommen bei PawFeed",
"welcomeSubtitle": "Hier ist dein Tier der Star. Drei Regeln, dann geht's los.",
"rule1Title": "Respektvoll bleiben",
"rule1Body": "Keine Beleidigungen, kein Mobbing, keine Drohungen.",
"rule2Title": "Tierwohl zuerst",
"rule2Body": "Nichts, was Tieren schadet oder Tierquälerei zeigt.",
"rule3Title": "Echt und sauber",
"rule3Body": "Keine illegalen oder anstößigen Inhalte, kein Spam, keine Fake-Profile.",
"termsShort": "Nutzungsbedingungen",
"privacyShort": "Datenschutz",
"welcomeContinue": "Verstanden, los geht's",
"welcomeContinueConsent": "Zustimmen und loslegen",
"nameTitle": "Wie heißt dein Tier?",
"nameSubtitle": "So steht es auf seinem Profil. Ändern geht jederzeit.",
"addNickname": "+ Spitzname hinzufügen",
"speciesTitle": "Was für ein Tier ist {{name}}?",
"speciesSubtitle": "Damit Tiere wie deins euch finden.",
"breedOptional": "Rasse (optional)",
"aboutTitle": "Erzähl uns von {{name}}",
"aboutSubtitle": "Beides freiwillig. Das macht das Profil lebendig.",
"aboutCreate": "Profil anlegen",
"storyPlaceholder": "Wie ist {{name}} zu dir gekommen?",
"photoTitle": "Ein Foto von {{name}}",
"photoSubtitle": "Mit Foto erkennen andere {{name}} sofort im Feed. Ändern geht jederzeit.",
"spiderTitle": "Spinnen ausblenden?",
"spiderSubtitle": "PawFeed ist auch für Spinnen-Halter da. Wenn dir vor Spinnenbildern graut, siehst du sie nirgends.",
"spiderToggle": "Spinnen ausblenden",
"spiderWhere": "Feed · Explore · Suche",
"doneTitle": "{{name}} ist jetzt auf PawFeed!",
"doneSubtitle": "Schau dich um, folge anderen Tieren und poste das erste Foto.",
"doneCta": "Zum Feed"
},
"petForm": {
"createTitle": "Füge dein erstes Tier hinzu",
@@ -373,7 +392,6 @@
},
"referrals": {
"title": "Freunde einladen",
"settingsLabel": "Einladungslink, Ränge und eingeladene Tiere",
"subtitle": "Teile deinen Link. Jedes Tier, das über deine Einladung kommt und seinen ersten Beitrag postet, bringt dich einen Rang weiter.",
"programPaused": "Das Empfehlungsprogramm ist gerade pausiert. Dein Link funktioniert, neue Einladungen werden aber im Moment nicht gezählt.",
"shareHeading": "Dein Einladungslink",
@@ -385,12 +403,8 @@
"countedInvites_zero": "Noch keine gezählte Einladung",
"countedInvites_one": "{{count}} gezählte Einladung",
"countedInvites_other": "{{count}} gezählte Einladungen",
"nextRank_one": "Noch {{count}} Einladung bis {{name}}",
"nextRank_other": "Noch {{count}} Einladungen bis {{name}}",
"allRanksReached": "Du hast alle Ränge erreicht. Danke!",
"achieved": "Erreicht",
"threshold_one": "{{count}} Einladung",
"threshold_other": "{{count}} Einladungen",
"inviteesHeading": "Über dich gekommen",
"inviteesEmpty": "Noch niemand. Teile deinen Link mit Freunden, die selbst ein Tier haben.",
"inviteeNoPet": "Noch kein Tier angelegt",
@@ -424,7 +438,18 @@
"source_SHELTER_OR_PARTNER": "Tierheim, Verein oder Laden",
"source_STORE_SEARCH": "App Store oder Google Play",
"source_PRESS": "Zeitung, Radio oder Blog",
"source_OTHER": "Anders"
"source_OTHER": "Anders",
"cardFirstRankWaiting": "Dein erster Rang wartet",
"cardYourRank": "Dein Rang",
"cardCodeLabel": "Dein Einladungscode",
"cardFirstInvite_one": "Lade 1 Freund ein, der mit seinem Tier mitmacht",
"cardFirstInvite_other": "Lade {{count}} Freunde ein, die mit ihrem Tier mitmachen",
"cardProgress_one": "{{done}} von {{count}} Freund bis {{name}}",
"cardProgress_other": "{{done}} von {{count}} Freunden bis {{name}}",
"cardRanks": "Ränge",
"ladderNext": "Als Nächstes",
"ladderThreshold_one": "{{count}} Freund",
"ladderThreshold_other": "{{count}} Freunde"
},
"storyViewer": {
"paw": "Pfote geben",
+47 -22
View File
@@ -276,28 +276,47 @@
"consentFirstHint": "Please tick the Terms of Service box first, then you can sign up this way too."
},
"onboarding": {
"addPetTitle": "Add pet",
"guidelinesTitle": "Before you get started",
"guidelinesSubtitle": "A quick overview of our community guidelines.",
"termsLink": "See the full details in our Terms of Service",
"privacyLink": "Privacy Policy (incl. cookies)",
"guidelinesContinue": "Got it, continue",
"consentTitle": "Two quick confirmations",
"consentSubtitle": "Before you create your first pet, we need your explicit consent to the following.",
"contentRightsLabel": "I confirm that I own all rights to the images and videos I upload and that they don't infringe on anyone else's copyright.",
"dataProcessingLabel": "I consent to my data being shared with Clerk (authentication), Cloudflare (media storage), Mux (video processing), and Supabase (database) to provide the service.",
"consentPrivacyLink": "Details on the service providers we use are in our Privacy Policy",
"consentContinue": "Confirm and continue",
"arachnophobiaTitle": "Arachnophobia helper",
"arachnophobiaSubtitle": "PawFeed now welcomes spider owners too. If spider photos give you the creeps, we'll hide them completely for you — in the feed, on Explore, and in search.",
"arachnophobiaCheckboxLabel": "Yes, please hide spider content for me.",
"arachnophobiaHint": "You can change this anytime under \"My Pets\".",
"avatarTitle": "Add a profile photo",
"avatarSubtitle": "Give your pet a face on PawFeed. You can change this anytime.",
"galleryButton": "Gallery",
"cameraButton": "Camera",
"uploadError": "Upload failed — please try again.",
"cameraPermissionDenied": "Access to the camera was not granted."
"cameraPermissionDenied": "Access to the camera was not granted.",
"back": "Back",
"close": "Close",
"saveError": "That didn't work. Please try again.",
"welcomeTitle": "Welcome to PawFeed",
"welcomeSubtitle": "Here your pet is the star. Three rules, then let's go.",
"rule1Title": "Be respectful",
"rule1Body": "No insults, bullying or threats.",
"rule2Title": "Animal welfare first",
"rule2Body": "Nothing that harms animals or shows animal cruelty.",
"rule3Title": "Keep it real",
"rule3Body": "No illegal or explicit content, no spam, no fake profiles.",
"termsShort": "Terms of Service",
"privacyShort": "Privacy Policy",
"welcomeContinue": "Got it, let's go",
"welcomeContinueConsent": "Agree and get started",
"nameTitle": "What's your pet's name?",
"nameSubtitle": "That's how it appears on its profile. You can change it anytime.",
"addNickname": "+ Add a nickname",
"speciesTitle": "What kind of animal is {{name}}?",
"speciesSubtitle": "So pets like yours can find you.",
"breedOptional": "Breed (optional)",
"aboutTitle": "Tell us about {{name}}",
"aboutSubtitle": "Both optional. It brings the profile to life.",
"aboutCreate": "Create profile",
"storyPlaceholder": "How did {{name}} come to you?",
"photoTitle": "A photo of {{name}}",
"photoSubtitle": "With a photo, others spot {{name}} right away in the feed. You can change it anytime.",
"spiderTitle": "Hide spiders?",
"spiderSubtitle": "PawFeed welcomes spider keepers too. If spider photos make you shudder, you won't see them anywhere.",
"spiderToggle": "Hide spiders",
"spiderWhere": "Feed · Explore · Search",
"doneTitle": "{{name}} is now on PawFeed!",
"doneSubtitle": "Look around, follow other pets and post the first photo.",
"doneCta": "Go to feed"
},
"petForm": {
"createTitle": "Add your first pet",
@@ -373,7 +392,6 @@
},
"referrals": {
"title": "Invite friends",
"settingsLabel": "Invite link, ranks and invited pets",
"subtitle": "Share your link. Every pet that joins through your invite and publishes its first post moves you up a rank.",
"programPaused": "The referral program is paused right now. Your link still works, but new invites are not counted at the moment.",
"shareHeading": "Your invite link",
@@ -385,12 +403,8 @@
"countedInvites_zero": "No counted invites yet",
"countedInvites_one": "{{count}} counted invite",
"countedInvites_other": "{{count}} counted invites",
"nextRank_one": "{{count}} more invite until {{name}}",
"nextRank_other": "{{count}} more invites until {{name}}",
"allRanksReached": "You reached every rank. Thank you!",
"achieved": "Reached",
"threshold_one": "{{count}} invite",
"threshold_other": "{{count}} invites",
"inviteesHeading": "Joined through you",
"inviteesEmpty": "Nobody yet. Share your link with friends who have a pet.",
"inviteeNoPet": "No pet created yet",
@@ -424,7 +438,18 @@
"source_SHELTER_OR_PARTNER": "Shelter, club or shop",
"source_STORE_SEARCH": "App Store or Google Play",
"source_PRESS": "Newspaper, radio or blog",
"source_OTHER": "Other"
"source_OTHER": "Other",
"cardFirstRankWaiting": "Your first rank is waiting",
"cardYourRank": "Your rank",
"cardCodeLabel": "Your invite code",
"cardFirstInvite_one": "Invite 1 friend who joins with their pet",
"cardFirstInvite_other": "Invite {{count}} friends who join with their pets",
"cardProgress_one": "{{done}} of {{count}} friend to {{name}}",
"cardProgress_other": "{{done}} of {{count}} friends to {{name}}",
"cardRanks": "Ranks",
"ladderNext": "Up next",
"ladderThreshold_one": "{{count}} friend",
"ladderThreshold_other": "{{count}} friends"
},
"storyViewer": {
"paw": "Give a paw",
+12
View File
@@ -0,0 +1,12 @@
import { Share } from "react-native";
import type { TFunction } from "i18next";
import { env } from "./env";
export function referralLink(code: string): string {
return `${env.apiUrl}/r/${code}`;
}
/** Opens the native share sheet with the owner's invite text + link (no native module needed → OTA-safe). */
export function shareReferralLink(code: string, t: TFunction): void {
void Share.share({ message: `${t("referrals.shareText", { code })} ${referralLink(code)}` });
}