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:
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 },
|
||||
});
|
||||
}
|
||||
File renamed without changes.
Reference in new issue
Block a user