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:
1 parent
0ec05835ff
commit
fdc41b7b69
25 files changed
+1616
-373
No files matched your search
+86
-64
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 }] },
|
||||
});
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}` });
|
||||
}
|
||||
Reference in new issue
Block a user