feat(mobile): Einladungskarte, neue Einladen-Seite und Onboarding in kurzen Schritten

- InviteCard in den Einstellungen und auf /invite: Rang (oder erster
  offener Rang gestrichelt), Code, animierter Fortschritt, Teilen
- /invite: Ränge als Zeitleiste, Status-Etiketten bei eingeladenen Tieren
- Onboarding: Willkommen (3 Regel-Karten + Bestätigungen), Name, Tierart-
  Chips, Beschreibung, Foto, Spinnen-Schalter, Herkunft, Abschluss mit
  Konfetti; Fortschritt, Zurück und Android-Zurück-Geste
- Regeln als App-Texte: getPendingUpdate lieferte neuen Nutzern die ganze
  Änderungshistorie der Rechtstexte statt der Community-Regeln

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:32 +02:00
1 parent b50c982f86
commit 0ec05835ff
5 files changed
-394

No files matched your search

File renamed without changes.
@@ -1,107 +0,0 @@
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ScrollText } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { env } from "../../lib/env";
import { Button } from "../Button";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
/**
* Port of web's src/components/legal/OnboardingGuidelines.tsx. Auto-skips
* via onContinue() once acknowledged, or if nothing has ever been published
* (legal.getPendingUpdate returns null) — matches web's admin-editable
* LegalDocumentVersion content, not a hardcoded first-run screen.
*/
export function OnboardingGuidelines({ onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: pending, isLoading } = useQuery(trpc.legal.getPendingUpdate.queryOptions());
const acknowledgeMutation = useMutation(
trpc.legal.acknowledge.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryOptions().queryKey });
onContinue();
},
})
);
const hasNothingToAcknowledge = !isLoading && (!pending || pending.length === 0);
useEffect(() => {
if (hasNothingToAcknowledge) onContinue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasNothingToAcknowledge]);
if (isLoading || hasNothingToAcknowledge) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.titleRow}>
<ScrollText color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>{t("onboarding.guidelinesTitle")}</Text>
</View>
<Text style={styles.subtitle}>{t("onboarding.guidelinesSubtitle")}</Text>
<View style={styles.card}>
{pending!.map((version) => (
<Text key={version.id} style={styles.summary}>
{version.changeSummary}
</Text>
))}
</View>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/nutzungsbedingungen`)}>
<Text style={styles.link}>{t("onboarding.termsLink")}</Text>
</Pressable>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>{t("onboarding.privacyLink")}</Text>
</Pressable>
<Button
title={acknowledgeMutation.isPending ? "…" : t("onboarding.guidelinesContinue")}
disabled={acknowledgeMutation.isPending}
onPress={() => acknowledgeMutation.mutate()}
style={styles.cta}
/>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
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 },
card: {
gap: 10,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
backgroundColor: colors.pureWhite,
marginBottom: 16,
},
summary: { ...typography.body },
link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 8 },
cta: { marginTop: 12 },
});
}
@@ -1,152 +0,0 @@
import { useMemo, useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { TextField } from "../TextField";
import { Button } from "../Button";
import { OptionPicker, type PickerOption } from "../OptionPicker";
import { BreedPicker } from "../pet/BreedPicker";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens } from "../../theme/tokens";
type Props = { onSuccess: (pet: { id: string; name: string }) => void };
/**
* Port of web's src/components/pet/PetForm.tsx in create mode — name,
* nickname, species/breed (data-driven, never hardcoded per CLAUDE.md),
* bio, adoption story. Avatar upload happens in a separate step after the
* pet exists (see onboarding/pet.tsx), matching web's two-phase flow.
*/
export function PetCreateForm({ onSuccess }: Props) {
const { t } = useTranslation();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const [name, setName] = useState("");
const [nickname, setNickname] = useState("");
const [speciesId, setSpeciesId] = useState<string | null>(null);
const [speciesName, setSpeciesName] = useState<string | null>(null);
const [breedId, setBreedId] = useState<string | null>(null);
const [breedName, setBreedName] = useState<string | null>(null);
const [bio, setBio] = useState("");
const [adoptionStory, setAdoptionStory] = useState("");
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation(
trpc.pets.create.mutationOptions({
onSuccess: (pet) => {
// Ohne dieses Invalidate bleibt ActivePetContext.pets (trpc.pets.list)
// auf dem alten "keine Tiere"-Stand stehen, bis die App neu startet —
// OnboardingRedirect wirft dann sofort wieder auf /onboarding/pet
// zurück, sobald man nach dem Avatar-Schritt in (app) landet.
queryClient.invalidateQueries({ queryKey: trpc.pets.list.queryOptions().queryKey });
onSuccess({ id: pet.id, name: pet.name });
},
onError: (err) => setError(err.message),
})
);
const canSubmit = name.trim().length > 0 && !!speciesId && !createMutation.isPending;
return (
<View style={styles.container}>
<Text style={styles.title}>{t("petForm.createTitle")}</Text>
<Text style={styles.subtitle}>{t("petForm.createSubtitle")}</Text>
<Text style={styles.label}>{t("petForm.name")}</Text>
<TextField placeholder={t("petForm.namePlaceholder")} value={name} onChangeText={setName} maxLength={30} />
<Text style={styles.label}>{t("petForm.nickname")}</Text>
<TextField placeholder={t("petForm.nicknamePlaceholder")} value={nickname} onChangeText={setNickname} maxLength={30} />
<Text style={styles.label}>{t("petForm.species")}</Text>
<SpeciesPicker
selectedName={speciesName}
onSelect={(option) => {
setSpeciesId(option.id);
setSpeciesName(option.name);
setBreedId(null);
setBreedName(null);
}}
/>
<Text style={styles.label}>{t("petForm.breed")}</Text>
<BreedPicker speciesId={speciesId} selectedName={breedName} onSelect={(o) => { setBreedId(o.id); setBreedName(o.name); }} />
<Text style={styles.label}>{t("petForm.bio")}</Text>
<TextField
placeholder={t("petForm.bioPlaceholder")}
value={bio}
onChangeText={setBio}
maxLength={150}
multiline
style={styles.multiline}
/>
<Text style={styles.label}>{t("petForm.adoptionStory")}</Text>
<TextField
placeholder={t("petForm.adoptionStoryPlaceholder")}
value={adoptionStory}
onChangeText={setAdoptionStory}
maxLength={500}
multiline
style={styles.multiline}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={createMutation.isPending ? "…" : t("petForm.createButton")}
disabled={!canSubmit}
onPress={() =>
createMutation.mutate({
name: name.trim(),
nickname: nickname.trim() || undefined,
speciesId: speciesId as string,
breedId: breedId ?? undefined,
bio: bio.trim() || undefined,
adoptionStory: adoptionStory.trim() || undefined,
})
}
style={styles.cta}
/>
</View>
);
}
function SpeciesPicker({ selectedName, onSelect }: { selectedName: string | null; onSelect: (o: PickerOption) => void }) {
const { t, i18n } = useTranslation();
const trpc = useTRPC();
const [open, setOpen] = useState(false);
const { data: species = [], isLoading } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language }));
return (
<OptionPicker
open={open}
onToggle={() => setOpen((v) => !v)}
selectedName={selectedName}
placeholder={t("petForm.selectSpecies")}
isLoading={isLoading}
options={species}
onSelect={(o) => {
onSelect(o);
setOpen(false);
}}
/>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { padding: 24, gap: 6 },
title: { ...typography.title, fontSize: 18, marginBottom: 4 },
subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 },
label: { ...typography.caption, marginTop: 10 },
multiline: { height: 80, textAlignVertical: "top", paddingTop: 12 },
error: { ...typography.body, color: colors.alertRed, marginTop: 8 },
cta: { marginTop: 16 },
});
}
@@ -1,135 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { View, Text, Pressable, ActivityIndicator, Linking, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck, Check } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { env } from "../../lib/env";
import { Button } from "../Button";
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
import { useTheme } from "../../context/ThemeContext";
type Props = { onContinue: () => void };
/**
* Port of web's src/components/legal/UploadConsent.tsx — two mandatory,
* blocking Willenserklärungen (content rights + data-processing consent,
* Gitea #3) shown once before the very first pet can be created. Skips
* automatically if already consented (legal.getUploadConsentStatus).
*/
export function UploadConsent({ onContinue }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const queryClient = useQueryClient();
const [contentRightsChecked, setContentRightsChecked] = useState(false);
const [dataProcessingChecked, setDataProcessingChecked] = useState(false);
const { data: status, isLoading } = useQuery(trpc.legal.getUploadConsentStatus.queryOptions());
const acknowledgeMutation = useMutation(
trpc.legal.acknowledgeUploadConsent.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.legal.getUploadConsentStatus.queryOptions().queryKey });
onContinue();
},
})
);
const alreadyConsented = !isLoading && status?.hasConsented === true;
useEffect(() => {
if (alreadyConsented) onContinue();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alreadyConsented]);
if (isLoading || alreadyConsented) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
const canContinue = contentRightsChecked && dataProcessingChecked;
return (
<View style={styles.container}>
<View style={styles.titleRow}>
<ShieldCheck color={colors.marigoldOrange} size={20} />
<Text style={styles.title}>{t("onboarding.consentTitle")}</Text>
</View>
<Text style={styles.subtitle}>{t("onboarding.consentSubtitle")}</Text>
<ConsentCheckbox
checked={contentRightsChecked}
onToggle={() => setContentRightsChecked((v) => !v)}
label={t("onboarding.contentRightsLabel")}
/>
<ConsentCheckbox
checked={dataProcessingChecked}
onToggle={() => setDataProcessingChecked((v) => !v)}
label={t("onboarding.dataProcessingLabel")}
/>
<Pressable onPress={() => Linking.openURL(`${env.apiUrl}/datenschutz`)}>
<Text style={styles.link}>{t("onboarding.consentPrivacyLink")}</Text>
</Pressable>
<Button
title={acknowledgeMutation.isPending ? "…" : t("onboarding.consentContinue")}
disabled={!canContinue || acknowledgeMutation.isPending}
onPress={() => acknowledgeMutation.mutate()}
style={styles.cta}
/>
</View>
);
}
function ConsentCheckbox({ checked, onToggle, label }: { checked: boolean; onToggle: () => void; label: string }) {
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
return (
<Pressable style={styles.checkboxRow} onPress={onToggle}>
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
{checked && <Check color={colors.pureWhite} size={14} />}
</View>
<Text style={styles.checkboxLabel}>{label}</Text>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 40 },
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: {
flexDirection: "row",
gap: 12,
padding: 14,
borderRadius: radii.xl,
borderWidth: 1,
borderColor: colors.hairlineGray,
marginBottom: 12,
},
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 },
link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 },
cta: { marginTop: 4 },
});
}