feat(mobile): Empfehlungsprogramm in der App

- Bildschirm "Freunde einladen" (/invite, aus den Einstellungen erreichbar):
  eigener Code mit nativem Teilen-Dialog, Rangleiter mit Fortschritt,
  eingeladene Tiere, nachträgliche Code-Eingabe im Einlösefenster
- Onboarding-Schritt "Wie hast du von PawFeed erfahren?" mit Code-Eingabe,
  einmal pro Owner (referrals.onboardingState)
- Rang-Abzeichen in PetIdentityBadges (Feed und Profil), Profilring in
  Rangfarbe, "Eingeladen von …" im Tierprofil
- Benachrichtigungen REFERRAL_JOINED / REFERRAL_RANK_UP
- Keine neuen nativen Module (React Native Share) — per OTA auslieferbar

Typecheck grün; nicht auf einem Gerät getestet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 06:30:11 +02:00
1 parent 4d0aa7ab9e
commit 78e16c9210
17 files changed
+725 -13

No files matched your search

@@ -20,6 +20,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
MENTION: "notifications.mention",
PAW_BACK: "notifications.pawBack",
PET_SUGGESTION: "notifications.petSuggestion",
REFERRAL_JOINED: "notifications.referralJoined",
};
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
@@ -28,6 +29,7 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
BIRTHDAY: "notifications.birthday",
ADOPTION_DAY: "notifications.adoptionDay",
REFERRAL_RANK_UP: "notifications.referralRankUp",
};
// Suffix only — the actor's name is rendered separately as its own tappable
@@ -56,7 +58,10 @@ function getNotificationRoute(item: NotificationItem): string | null {
switch (item.type) {
case "FOLLOW":
case "PET_SUGGESTION":
case "REFERRAL_JOINED":
return item.actorPet ? `/pets/${item.actorPet.id}` : null;
case "REFERRAL_RANK_UP":
return "/invite";
case "BIRTHDAY":
case "ADOPTION_DAY":
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
+1
View File
@@ -50,6 +50,7 @@ export default function AppLayout() {
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
<Stack.Screen name="invite" options={{ ...headerOptions, title: t("screens.invite") }} />
<Stack.Screen name="delete-account" options={{ ...headerOptions, title: t("screens.deleteAccount") }} />
<Stack.Screen
name="post-new"
+127
View File
@@ -0,0 +1,127 @@
import { useMemo } from "react";
import { ActivityIndicator, Pressable, ScrollView, Share, 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 { 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 { RankLadder } from "../../src/components/referrals/RankLadder";
import { RedeemCodeForm } from "../../src/components/referrals/RedeemCodeForm";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
const STATUS_KEYS = {
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.
*/
export default function InviteScreen() {
const { t } = useTranslation();
const router = useRouter();
const trpc = useTRPC();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const bottomPadding = useBottomSafePadding();
const { ranks } = useReferralRanks();
const { data, isLoading } = useQuery(trpc.referrals.me.queryOptions());
if (isLoading || !data) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
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}` });
}
return (
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
<Text style={styles.subtitle}>{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>
</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>
{data.canRedeem && (
<View style={styles.card}>
<Text style={styles.heading}>{t("referrals.redeemHeading")}</Text>
<Text style={styles.muted}>{t("referrals.redeemSubtitle")}</Text>
<RedeemCodeForm />
</View>
)}
<Text style={styles.footnote}>{t("referrals.rulesNote")}</Text>
</ScrollView>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
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 },
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" },
inviteeRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
inviteeName: { ...typography.body, flex: 1, fontWeight: "600" },
footnote: { ...typography.body, fontSize: 12, color: colors.quietGray },
});
}
+14 -1
View File
@@ -8,6 +8,7 @@ import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
import { ReferralAvatarRing } from "../../../../src/components/referrals/ReferralAvatarRing";
import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges";
import { PostCard } from "../../../../src/components/PostCard";
import { Button } from "../../../../src/components/Button";
@@ -126,6 +127,7 @@ export default function PetProfileScreen() {
)}
ListHeaderComponent={
<View style={styles.header}>
<ReferralAvatarRing owner={pet.owner}>
{pet.avatarKey ? (
<Pressable
onPress={() =>
@@ -140,11 +142,20 @@ export default function PetProfileScreen() {
) : (
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
)}
</ReferralAvatarRing>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(pet)}</Text>
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
</View>
<Text style={styles.species}>{pet.species.name}</Text>
{pet.invitedByPet && (
<Text style={styles.invitedBy}>
{t("referrals.invitedBy")}{" "}
<Text style={styles.invitedByName} onPress={() => router.push(`/pets/${pet.invitedByPet?.id}`)}>
{pet.invitedByPet.name}
</Text>
</Text>
)}
<View style={styles.statsRow}>
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
@@ -216,6 +227,8 @@ function makeStyles(colors: ColorTokens) {
nameRow: { flexDirection: "row", alignItems: "center", marginTop: 8 },
name: { ...typography.title },
species: { ...typography.body, color: colors.quietGray },
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
invitedByName: { color: colors.ink, fontWeight: "600" },
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
stat: { alignItems: "center" },
statCount: { ...typography.title, fontSize: 18 },
+2
View File
@@ -4,6 +4,7 @@ import { useTheme } from "../../src/context/ThemeContext";
import { AppearanceSection } from "../../src/components/settings/AppearanceSection";
import { LanguageSection } from "../../src/components/settings/LanguageSection";
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
import { InviteFriendsSection } from "../../src/components/settings/InviteFriendsSection";
import { LegalSection } from "../../src/components/settings/LegalSection";
import { LogoutSection } from "../../src/components/settings/LogoutSection";
import { DeleteAccountSection } from "../../src/components/settings/DeleteAccountSection";
@@ -41,6 +42,7 @@ export default function SettingsScreen() {
return (
<FooterErrorBoundary>
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
<InviteFriendsSection />
<AppearanceSection />
<LanguageSection />
<PetSuggestionsSection />
+25 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { FlatList } from "react-native";
import { ActivityIndicator, FlatList, View } from "react-native";
import { useRouter } from "expo-router";
import { useActivePet } from "../../src/context/ActivePetContext";
import { OnboardingGuidelines } from "../../src/components/onboarding/OnboardingGuidelines";
@@ -7,12 +7,16 @@ 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";
/**
* Port of web's src/app/onboarding/pet/page.tsx — same five-step flow and
* step order: community guidelines -> upload-rights/data-processing consent
* -> Arachnophobie-Helfer opt-in -> pet profile -> avatar photo. Lives as
* 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.
*/
@@ -24,6 +28,15 @@ export default function OnboardingPetScreen() {
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);
const trpc = useTRPC();
// Erst nach dem Anlegen des Tiers abfragen — vorher existiert die Owner-Zeile ggf. noch nicht.
const { data: referralState } = useQuery({
...trpc.referrals.onboardingState.queryOptions(),
enabled: !!createdPet,
});
const isReferralStateLoading = !!createdPet && !acquisitionStepDone && referralState === undefined;
const showAcquisitionStep = !!createdPet && !acquisitionStepDone && !!referralState?.showStep;
const finish = () => router.replace("/(app)");
@@ -34,6 +47,14 @@ export default function OnboardingPetScreen() {
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} />;
} else {
+8 -2
View File
@@ -2,21 +2,27 @@ import { View, StyleSheet } from "react-native";
import { BadgeCheck, Sparkles } from "lucide-react-native";
import { isActiveSponsor } from "../lib/sponsor";
import { colors } from "../theme/tokens";
import { ReferralIcon } from "../lib/referral-icons";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
type Props = {
isVerified?: boolean;
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
referral?: ReferralOwnerFields | null;
};
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
export function PetIdentityBadges({ isVerified, sponsor }: Props) {
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
if (!isVerified && !showSponsor) return null;
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
if (!isVerified && !showSponsor && !rank) return null;
return (
<View style={styles.row}>
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
</View>
);
}
+7 -2
View File
@@ -59,7 +59,12 @@ export type PostCardPost = {
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
owner?: {
sponsorSince: Date | string | null;
sponsorPeriodEnd: Date | string | null;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
};
};
@@ -143,7 +148,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
@@ -0,0 +1,107 @@
import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react-native";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { RedeemCodeForm } from "../referrals/RedeemCodeForm";
import { radii, getTypography, type ColorTokens } from "../../theme/tokens";
const SOURCES = [
"FRIENDS",
"TIKTOK",
"INSTAGRAM",
"YOUTUBE",
"FACEBOOK",
"SHELTER_OR_PARTNER",
"STORE_SEARCH",
"PRESS",
"OTHER",
] as const;
type Source = (typeof SOURCES)[number];
type Props = { canRedeem: boolean; onDone: () => void };
/**
* Port of web's src/components/referrals/AcquisitionStep.tsx — freiwillige
* Herkunftsangabe nach dem ersten Tier, bei „Freunde“ mit Code-Eingabe.
* Kein Vorausfüllen wie im Web (dort per Cookie aus /r/<code>): nach dem
* Store-Umweg gibt es in der App keinen Weg, den Link-Code mitzunehmen.
*/
export function AcquisitionStep({ canRedeem, onDone }: Props) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const [source, setSource] = useState<Source | null>(null);
const [inviterName, setInviterName] = useState<string | null | undefined>(undefined);
const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone }));
function handleContinue() {
if (source) saveSource.mutate({ source });
else onDone();
}
return (
<View style={styles.container}>
<Text style={styles.title}>{t("referrals.onboardingTitle")}</Text>
<Text style={styles.subtitle}>{t("referrals.onboardingSubtitle")}</Text>
<View style={styles.chips} accessibilityRole="radiogroup">
{SOURCES.map((option) => {
const isSelected = source === option;
return (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityState={{ checked: isSelected }}
onPress={() => setSource(option)}
style={[styles.chip, isSelected && styles.chipSelected]}
>
<Text style={[styles.chipText, isSelected && styles.chipTextSelected]}>{t(`referrals.source_${option}`)}</Text>
</Pressable>
);
})}
</View>
{source === "FRIENDS" && canRedeem && (
<View style={styles.redeemBox}>
{inviterName !== undefined ? (
<View style={styles.redeemedRow}>
<CheckCircle2 size={18} color={colors.marigoldOrange} />
<Text style={styles.redeemedText}>
{inviterName ? t("referrals.redeemed", { name: inviterName }) : t("referrals.redeemedGeneric")}
</Text>
</View>
) : (
<RedeemCodeForm onRedeemed={setInviterName} />
)}
</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>
);
}
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 },
chipText: { ...typography.body, fontSize: 14 },
chipTextSelected: { color: colors.marigoldDeep, fontWeight: "600" },
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 },
});
}
@@ -0,0 +1,90 @@
import { useMemo } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
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";
type Props = {
ranks: ReferralRankView[];
achievedRankIds: ReadonlySet<string>;
qualifiedCount: number;
nextRankId: string | null;
};
/** Port of web's RankLadder — Fortschritt zum nächsten Rang plus alle aktiven Ränge. */
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;
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<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) => {
const isAchieved = achievedRankIds.has(rank.id);
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>
<View style={styles.rowText}>
<Text style={styles.rankName}>{rank.name}</Text>
<Text style={styles.rankDescription}>{rank.description}</Text>
</View>
{isAchieved ? (
<Check size={16} color={colors.marigoldOrange} accessibilityLabel={t("referrals.achieved")} />
) : (
<Text style={styles.muted}>{t("referrals.threshold", { count: rank.threshold })}</Text>
)}
</View>
);
})}
</View>
</View>
);
}
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 },
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 },
});
}
@@ -0,0 +1,74 @@
import { useMemo, useState } from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../../api/trpc";
import { useTheme } from "../../context/ThemeContext";
import { Button } from "../Button";
import { TextField } from "../TextField";
import { getTypography, type ColorTokens } from "../../theme/tokens";
/** Fehler-Schlüssel aus REFERRAL_ERRORS (src/lib/referrals.ts im Web) → Übersetzungsschlüssel. */
const ERROR_KEYS: Record<string, string> = {
REFERRAL_PROGRAM_PAUSED: "referrals.errorPaused",
REFERRAL_CODE_INVALID: "referrals.errorInvalid",
REFERRAL_OWN_CODE: "referrals.errorOwnCode",
REFERRAL_ALREADY_REDEEMED: "referrals.errorAlreadyRedeemed",
REFERRAL_WINDOW_EXPIRED: "referrals.errorWindowExpired",
REFERRAL_NO_PET: "referrals.errorNoPet",
};
/** Port of web's RedeemCodeForm — genutzt im Onboarding und auf dem Einladen-Bildschirm. */
export function RedeemCodeForm({ onRedeemed }: { onRedeemed?: (inviterPetName: string | null) => void }) {
const { t } = useTranslation();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const trpc = useTRPC();
const qc = useQueryClient();
const [code, setCode] = useState("");
const [errorKey, setErrorKey] = useState<string | null>(null);
const redeem = useMutation(
trpc.referrals.redeem.mutationOptions({
onSuccess: (result) => {
setErrorKey(null);
void qc.invalidateQueries({ queryKey: trpc.referrals.me.queryKey() });
onRedeemed?.(result.inviterPetName);
},
onError: (err) => {
const isRateLimited = err.data?.code === "TOO_MANY_REQUESTS";
setErrorKey(isRateLimited ? "referrals.errorTooMany" : (ERROR_KEYS[err.message] ?? "referrals.errorGeneric"));
},
})
);
return (
<View style={styles.container}>
<Text style={styles.label}>{t("referrals.redeemLabel")}</Text>
<TextField
value={code}
onChangeText={(value) => setCode(value.toUpperCase())}
placeholder="BUBI-7K3M"
autoCapitalize="characters"
autoCorrect={false}
maxLength={32}
accessibilityLabel={t("referrals.redeemLabel")}
/>
{errorKey && <Text style={styles.error}>{t(errorKey)}</Text>}
<Button
title={redeem.isPending ? "…" : t("referrals.redeemCta")}
disabled={!code.trim() || redeem.isPending}
onPress={() => redeem.mutate({ code: code.trim() })}
/>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
container: { gap: 8 },
label: { ...typography.body, fontWeight: "600" },
error: { ...typography.body, fontSize: 13, color: colors.alertRed },
});
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../../hooks/useReferralRanks";
const RING_WIDTH = 3;
const RING_GAP = 2;
/**
* Port of web's ReferralAvatarRing — Ring in der Rangfarbe um das Tierbild
* im Profilkopf (Vorteil PROFILE_RING). Nur im Profil, nicht im Feed: dort
* steht der Ring (PawRing) für Storys.
*/
export function ReferralAvatarRing({ owner, children }: { owner: ReferralOwnerFields | null | undefined; children: ReactNode }) {
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
if (!rank) return <>{children}</>;
return (
<View style={{ borderWidth: RING_WIDTH, borderColor: rank.color, borderRadius: 999, padding: RING_GAP }}>{children}</View>
);
}
@@ -0,0 +1,36 @@
import { useMemo } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { ChevronRight, Gift } from "lucide-react-native";
import { useTheme } from "../../context/ThemeContext";
import { getTypography, type ColorTokens } from "../../theme/tokens";
import { sectionTitleStyle } from "./sectionTitleStyle";
/** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */
export function InviteFriendsSection() {
const { t } = useTranslation();
const router = useRouter();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
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>
</View>
);
}
function makeStyles(colors: ColorTokens) {
const typography = getTypography(colors);
return StyleSheet.create({
sectionTitle: sectionTitleStyle(colors),
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 },
label: { ...typography.body, flex: 1 },
});
}
+48
View File
@@ -0,0 +1,48 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useTRPC } from "../api/trpc";
const RANKS_STALE_MS = 30 * 60 * 1000; // Ränge ändern sich selten — einmal laden, lange halten
export type ReferralRankView = {
id: string;
threshold: number;
name: string;
description: string;
icon: string;
color: string;
perks: string[];
isActive: boolean;
};
export type ReferralOwnerFields = {
referralRankId?: string | null;
referralPerks?: readonly string[] | null;
};
/** Port of web's src/hooks/useReferralRanks.ts — Rangliste in der aktuellen App-Sprache. */
export function useReferralRanks(): { ranks: ReferralRankView[]; byId: Map<string, ReferralRankView> } {
const trpc = useTRPC();
const { i18n } = useTranslation();
const isGerman = i18n.language.startsWith("de");
const { data = [] } = useQuery({ ...trpc.referrals.ranks.queryOptions(), staleTime: RANKS_STALE_MS });
const ranks = data.map((rank) => ({
id: rank.id,
threshold: rank.threshold,
name: isGerman ? rank.nameDe : rank.nameEn,
description: isGerman ? rank.descriptionDe : rank.descriptionEn,
icon: rank.icon,
color: rank.color,
perks: rank.perks,
isActive: rank.isActive,
}));
return { ranks, byId: new Map(ranks.map((rank) => [rank.id, rank])) };
}
/** Rang mit dem Vorteil `perk` für diesen Owner, sonst null (Vorteile sind kumulativ, siehe Owner.referralPerks). */
export function useOwnerRankWithPerk(owner: ReferralOwnerFields | null | undefined, perk: string): ReferralRankView | null {
const { byId } = useReferralRanks();
if (!owner?.referralRankId || !owner.referralPerks?.includes(perk)) return null;
return byId.get(owner.referralRankId) ?? null;
}
+60 -2
View File
@@ -193,7 +193,9 @@
"birthday": "🎂 Alles Gute zum Geburtstag!",
"adoptionDay": "🏠 Heute ist der Adoptionstag!",
"petSuggestion": "könnte dir gefallen!",
"empty": "Keine Benachrichtigungen."
"empty": "Keine Benachrichtigungen.",
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!"
},
"pets": {
"empty": "Noch keine Tiere angelegt.",
@@ -312,7 +314,8 @@
"newPost": "Neuer Beitrag",
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein",
"deleteAccount": "Konto löschen"
"deleteAccount": "Konto löschen",
"invite": "Freunde einladen"
},
"block": {
"menuItem": "{{petName}} blockieren",
@@ -341,5 +344,60 @@
"deleting": "Konto wird gelöscht…",
"doneTitle": "Dein Konto wurde gelöscht.",
"genericError": "Etwas ist schiefgelaufen. Bitte versuch es erneut."
},
"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",
"shareSubtitle": "Unbegrenzt oft nutzbar. Wer sich ohne Link registriert, kann deinen Code auch im Onboarding eingeben.",
"yourCode": "Dein Code",
"share": "Link teilen",
"shareText": "Mein Tier ist auf PawFeed, dem sozialen Netzwerk für Haustiere. Mit meinem Code {{code}} sind wir direkt verbunden:",
"ranksHeading": "Deine Ränge",
"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",
"statusCounted": "Zählt",
"statusWaiting": "Wartet auf ersten Beitrag",
"statusInReview": "Wird geprüft",
"statusRevoked": "Aberkannt",
"redeemHeading": "Wurdest du eingeladen?",
"redeemSubtitle": "Gib den Code der Person ein, die dich eingeladen hat. Das geht nur in den ersten Tagen nach der Registrierung.",
"redeemLabel": "Einladungscode",
"redeemCta": "Einlösen",
"redeemed": "Eingelöst. Du und {{name}} folgt euch jetzt.",
"redeemedGeneric": "Code eingelöst.",
"errorPaused": "Das Empfehlungsprogramm ist gerade pausiert.",
"errorInvalid": "Diesen Code gibt es nicht. Prüf die Schreibweise, z. B. BUBI-7K3M.",
"errorOwnCode": "Das ist dein eigener Code. Gib den Code der Person ein, die dich eingeladen hat.",
"errorAlreadyRedeemed": "Du hast schon einen Einladungscode eingelöst.",
"errorWindowExpired": "Einladungscodes können nur in den ersten Tagen nach der Registrierung eingelöst werden.",
"errorNoPet": "Leg zuerst ein Tier an, dann kannst du den Code einlösen.",
"errorTooMany": "Zu viele Versuche. Bitte warte ein paar Minuten.",
"errorGeneric": "Das hat nicht geklappt. Bitte versuch es noch einmal.",
"rulesNote": "Eine Einladung zählt, sobald das eingeladene Tier angelegt ist und seinen ersten Beitrag veröffentlicht hat. Ränge sind digitale Auszeichnungen ohne Geldwert. Einmal erreichte Ränge bleiben erhalten, außer bei Missbrauch.",
"invitedBy": "Eingeladen von",
"onboardingTitle": "Wie hast du von PawFeed erfahren?",
"onboardingSubtitle": "Freiwillig. Es hilft uns zu verstehen, wo wir neue Tierfreunde erreichen.",
"source_FRIENDS": "Freunde oder Familie",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"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"
}
}
+60 -2
View File
@@ -193,7 +193,9 @@
"birthday": "🎂 Happy birthday!",
"adoptionDay": "🏠 Today is adoption day!",
"petSuggestion": "might be a good match!",
"empty": "No notifications."
"empty": "No notifications.",
"referralJoined": "joined PawFeed through your invite.",
"referralRankUp": "You reached a new referral rank!"
},
"pets": {
"empty": "No pets added yet.",
@@ -312,7 +314,8 @@
"newPost": "New post",
"newStory": "New story",
"newMilestone": "New milestone",
"deleteAccount": "Delete account"
"deleteAccount": "Delete account",
"invite": "Invite friends"
},
"block": {
"menuItem": "Block {{petName}}",
@@ -341,5 +344,60 @@
"deleting": "Deleting account…",
"doneTitle": "Your account has been deleted.",
"genericError": "Something went wrong. Please try again."
},
"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",
"shareSubtitle": "Use it as often as you like. People who sign up without the link can still enter your code during onboarding.",
"yourCode": "Your code",
"share": "Share link",
"shareText": "My pet is on PawFeed, the social network for pets. Use my code {{code}} and we are connected right away:",
"ranksHeading": "Your ranks",
"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",
"statusCounted": "Counts",
"statusWaiting": "Waiting for first post",
"statusInReview": "Under review",
"statusRevoked": "Revoked",
"redeemHeading": "Were you invited?",
"redeemSubtitle": "Enter the code of the person who invited you. This only works during the first days after signing up.",
"redeemLabel": "Invite code",
"redeemCta": "Redeem",
"redeemed": "Redeemed. You and {{name}} now follow each other.",
"redeemedGeneric": "Code redeemed.",
"errorPaused": "The referral program is paused right now.",
"errorInvalid": "That code does not exist. Check the spelling, e.g. BUBI-7K3M.",
"errorOwnCode": "That is your own code. Enter the code of the person who invited you.",
"errorAlreadyRedeemed": "You already redeemed an invite code.",
"errorWindowExpired": "Invite codes can only be redeemed during the first days after signing up.",
"errorNoPet": "Create a pet first, then you can redeem the code.",
"errorTooMany": "Too many attempts. Please wait a few minutes.",
"errorGeneric": "That did not work. Please try again.",
"rulesNote": "An invite counts once the invited pet is created and has published its first post. Ranks are digital awards without monetary value. Ranks you reach stay with you unless the program is abused.",
"invitedBy": "Invited by",
"onboardingTitle": "How did you hear about PawFeed?",
"onboardingSubtitle": "Optional. It helps us understand where we reach new pet lovers.",
"source_FRIENDS": "Friends or family",
"source_TIKTOK": "TikTok",
"source_INSTAGRAM": "Instagram",
"source_YOUTUBE": "YouTube",
"source_FACEBOOK": "Facebook",
"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"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { createElement } from "react";
import {
Award,
Crown,
Flame,
Gem,
Heart,
Leaf,
Medal,
PawPrint,
Rocket,
Shield,
Star,
Trophy,
type LucideIcon,
type LucideProps,
} from "lucide-react-native";
/**
* Gegenstück zu REFERRAL_ICON_KEYS in src/lib/referral-ranks.ts (Web). Die
* Schlüssel müssen dort und hier identisch bleiben — das Admin-Panel bietet
* nur Symbole an, die es in beiden lucide-Paketen gibt.
*/
const ICONS: Record<string, LucideIcon> = {
"paw-print": PawPrint,
heart: Heart,
shield: Shield,
star: Star,
award: Award,
crown: Crown,
gem: Gem,
medal: Medal,
trophy: Trophy,
flame: Flame,
rocket: Rocket,
leaf: Leaf,
};
/** Rang-Symbol per Schlüssel; unbekannte Schlüssel fallen auf die Pfote zurück. */
export function ReferralIcon({ icon, ...props }: { icon: string } & LucideProps) {
return createElement(ICONS[icon] ?? PawPrint, props);
}