From 78e16c9210cbdf07193d4bb6f24e4e4e274bcb5c Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 1 Oct 2026 06:30:11 +0200 Subject: [PATCH] feat(mobile): Empfehlungsprogramm in der App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- mobile/app/(app)/(tabs)/notifications.tsx | 5 + mobile/app/(app)/_layout.tsx | 1 + mobile/app/(app)/invite.tsx | 127 ++++++++++++++++++ mobile/app/(app)/pets/[petId]/index.tsx | 15 ++- mobile/app/(app)/settings.tsx | 2 + mobile/app/onboarding/pet.tsx | 29 +++- mobile/src/components/PetIdentityBadges.tsx | 10 +- mobile/src/components/PostCard.tsx | 9 +- .../components/onboarding/AcquisitionStep.tsx | 107 +++++++++++++++ .../src/components/referrals/RankLadder.tsx | 90 +++++++++++++ .../components/referrals/RedeemCodeForm.tsx | 74 ++++++++++ .../referrals/ReferralAvatarRing.tsx | 19 +++ .../settings/InviteFriendsSection.tsx | 36 +++++ mobile/src/hooks/useReferralRanks.ts | 48 +++++++ mobile/src/i18n/locales/de.json | 62 ++++++++- mobile/src/i18n/locales/en.json | 62 ++++++++- mobile/src/lib/referral-icons.tsx | 42 ++++++ 17 files changed, 725 insertions(+), 13 deletions(-) create mode 100644 mobile/app/(app)/invite.tsx create mode 100644 mobile/src/components/onboarding/AcquisitionStep.tsx create mode 100644 mobile/src/components/referrals/RankLadder.tsx create mode 100644 mobile/src/components/referrals/RedeemCodeForm.tsx create mode 100644 mobile/src/components/referrals/ReferralAvatarRing.tsx create mode 100644 mobile/src/components/settings/InviteFriendsSection.tsx create mode 100644 mobile/src/hooks/useReferralRanks.ts create mode 100644 mobile/src/lib/referral-icons.tsx diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 2f46029..25bd357 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -20,6 +20,7 @@ const ACTOR_MESSAGE_KEYS: Partial> = { 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> = { const SELF_MESSAGE_KEYS: Partial> = { 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; diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index e338305..91ffbf9 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -50,6 +50,7 @@ export default function AppLayout() { + makeStyles(colors), [colors]); + const bottomPadding = useBottomSafePadding(); + const { ranks } = useReferralRanks(); + const { data, isLoading } = useQuery(trpc.referrals.me.queryOptions()); + + if (isLoading || !data) { + return ( + + + + ); + } + + 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 ( + + {t("referrals.subtitle")} + + {!data.programActive && {t("referrals.programPaused")}} + + + {t("referrals.shareHeading")} + {t("referrals.shareSubtitle")} + + {t("referrals.yourCode")} + {data.code} + + + + {t("referrals.share")} + + + + + + + {t("referrals.inviteesHeading")} + {data.invitees.length === 0 ? ( + {t("referrals.inviteesEmpty")} + ) : ( + data.invitees.map((invitee, index) => ( + invitee.pet && router.push(`/pets/${invitee.pet.id}`)} + > + {invitee.pet && } + {invitee.pet?.name ?? t("referrals.inviteeNoPet")} + {t(STATUS_KEYS[invitee.status])} + + )) + )} + + + {data.canRedeem && ( + + {t("referrals.redeemHeading")} + {t("referrals.redeemSubtitle")} + + + )} + + {t("referrals.rulesNote")} + + ); +} + +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 }, + }); +} diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx index 92007e6..a4af221 100644 --- a/mobile/app/(app)/pets/[petId]/index.tsx +++ b/mobile/app/(app)/pets/[petId]/index.tsx @@ -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={ + {pet.avatarKey ? ( @@ -140,11 +142,20 @@ export default function PetProfileScreen() { ) : ( )} + {formatPetName(pet)} - + {pet.species.name} + {pet.invitedByPet && ( + + {t("referrals.invitedBy")}{" "} + router.push(`/pets/${pet.invitedByPet?.id}`)}> + {pet.invitedByPet.name} + + + )} 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 }, diff --git a/mobile/app/(app)/settings.tsx b/mobile/app/(app)/settings.tsx index 0ea0a9e..699c066 100644 --- a/mobile/app/(app)/settings.tsx +++ b/mobile/app/(app)/settings.tsx @@ -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 ( + diff --git a/mobile/app/onboarding/pet.tsx b/mobile/app/onboarding/pet.tsx index 81bd60b..d8e1e53 100644 --- a/mobile/app/onboarding/pet.tsx +++ b/mobile/app/onboarding/pet.tsx @@ -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 = setUploadConsentGiven(true)} />; } else if (!arachnophobiaStepDone) { content = setArachnophobiaStepDone(true)} />; + } else if (isReferralStateLoading) { + content = ( + + + + ); + } else if (showAcquisitionStep) { + content = setAcquisitionStepDone(true)} />; } else if (createdPet) { content = ; } else { diff --git a/mobile/src/components/PetIdentityBadges.tsx b/mobile/src/components/PetIdentityBadges.tsx index 6156e5a..5a243ea 100644 --- a/mobile/src/components/PetIdentityBadges.tsx +++ b/mobile/src/components/PetIdentityBadges.tsx @@ -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 ( {isVerified && } {showSponsor && } + {rank && } ); } diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index b86fbd4..a2841f5 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -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 {formatPetName(post.pet)} - + {isTrending && ( diff --git a/mobile/src/components/onboarding/AcquisitionStep.tsx b/mobile/src/components/onboarding/AcquisitionStep.tsx new file mode 100644 index 0000000..da6a204 --- /dev/null +++ b/mobile/src/components/onboarding/AcquisitionStep.tsx @@ -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/): 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(null); + const [inviterName, setInviterName] = useState(undefined); + + const saveSource = useMutation(trpc.referrals.setAcquisitionSource.mutationOptions({ onSettled: onDone })); + + function handleContinue() { + if (source) saveSource.mutate({ source }); + else onDone(); + } + + return ( + + {t("referrals.onboardingTitle")} + {t("referrals.onboardingSubtitle")} + + + {SOURCES.map((option) => { + const isSelected = source === option; + return ( + setSource(option)} + style={[styles.chip, isSelected && styles.chipSelected]} + > + {t(`referrals.source_${option}`)} + + ); + })} + + + {source === "FRIENDS" && canRedeem && ( + + {inviterName !== undefined ? ( + + + + {inviterName ? t("referrals.redeemed", { name: inviterName }) : t("referrals.redeemedGeneric")} + + + ) : ( + + )} + + )} + +