feat(i18n): resolve Species/Breed display names via SpeciesTranslation/BreedTranslation
User found "Cat" showing English on a German-locale profile page. Root cause: Species.name/Breed.name are the canonical English values; a SpeciesTranslation/BreedTranslation table + seed data (Hund/Katze/Vogel etc.) already existed in the schema, and PetForm's species/breed picker already used it correctly via an explicit locale input param — but every *display* surface (pet profile, PetCard, Sidebar/MobileNav, search, explore, followers/following lists, mention @-search, health-card) just read the raw untranslated name. Adds `locale` to the tRPC context (createTRPCContext reads the NEXT_LOCALE cookie the same way i18n/request.ts does), so read procedures can resolve translations without every caller needing to pass a locale param. New src/lib/localized-name.ts::pickLocalizedName() does the lookup with a same-shape fallback to the raw name. Updated: pets.ts (list/byId/getProfile/update), search.ts (pets/byOwner), explore.ts (listPets), follows.ts (listFollowers/listFollowing). health-card/[token]/page.tsx (public, unauthenticated, intentionally all-German) resolves with a fixed "de" locale instead of ctx.locale, consistent with its existing hardcoded WeightChart locale. Verified the seed data is actually present in the production DB (SpeciesTranslation has Dog→Hund, Cat→Katze, Bird→Vogel) — this fix is immediately effective on deploy, not blocked on a missing seed run.
This commit is contained in:
1 parent
b7719397e6
commit
25fa2a3bec
10 files changed
+138
-30
No files matched your search
@@ -79,10 +79,12 @@ function createTestCaller(
|
||||
type CallerCtx = {
|
||||
userId: string | null;
|
||||
prisma: import("@prisma/client").PrismaClient;
|
||||
locale: string;
|
||||
};
|
||||
const ctx: CallerCtx = {
|
||||
userId,
|
||||
prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
|
||||
locale: "en",
|
||||
};
|
||||
return mediaRouter.createCaller(ctx);
|
||||
}
|
||||
|
||||
@@ -54,10 +54,11 @@ function createTestCaller(userId: string, mockPrisma: ReturnType<typeof createMo
|
||||
// petsRouter.createCaller accepts the same context shape as createTRPCContext returns.
|
||||
// Cast the mock to the expected PrismaClient type — mock has the same interface for
|
||||
// tested methods.
|
||||
type CallerCtx = { userId: string | null; prisma: import("@prisma/client").PrismaClient };
|
||||
type CallerCtx = { userId: string | null; prisma: import("@prisma/client").PrismaClient; locale: string };
|
||||
const ctx: CallerCtx = {
|
||||
userId,
|
||||
prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
|
||||
locale: "en",
|
||||
};
|
||||
return petsRouter.createCaller(ctx);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { PrintButton } from "@/components/health/PrintButton";
|
||||
import { AutoPrint } from "@/components/health/AutoPrint";
|
||||
import { WeightChart } from "@/components/health/WeightChart";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>;
|
||||
@@ -41,8 +42,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
|
||||
include: {
|
||||
pet: {
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: "de" } } } },
|
||||
breed: { include: { translations: { where: { locale: "de" } } } },
|
||||
weightLogs: { orderBy: { date: "desc" }, take: 20 },
|
||||
vetVisits: { orderBy: { date: "desc" }, take: 10 },
|
||||
vaccines: { orderBy: { dateGiven: "desc" } },
|
||||
@@ -81,6 +82,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
|
||||
|
||||
const { pet } = card;
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
const speciesName = pickLocalizedName(pet.species.name, pet.species.translations, "de");
|
||||
const breedName = pet.breed ? pickLocalizedName(pet.breed.name, pet.breed.translations, "de") : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
@@ -110,8 +113,8 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{pet.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary">{pet.species.name}</Badge>
|
||||
{pet.breed && <Badge variant="outline">{pet.breed.name}</Badge>}
|
||||
<Badge variant="secondary">{speciesName}</Badge>
|
||||
{breedName && <Badge variant="outline">{breedName}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const LOCALES = ["en", "de"] as const;
|
||||
export const LOCALES = ["en", "de"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
interface NameTranslation {
|
||||
locale: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a translated display name from a SpeciesTranslation/BreedTranslation
|
||||
* row set, falling back to the canonical (English) `name` field when no
|
||||
* translation matches the requested locale (e.g. locale is "en", or the
|
||||
* translation just hasn't been seeded for that locale).
|
||||
*/
|
||||
export function pickLocalizedName(
|
||||
name: string,
|
||||
translations: NameTranslation[] | null | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
return translations?.find((t) => t.locale === locale)?.name ?? name;
|
||||
}
|
||||
+11
-1
@@ -1,14 +1,24 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { LOCALES, defaultLocale } from "@/i18n/request";
|
||||
|
||||
/**
|
||||
* tRPC context — created per request in the API route handler.
|
||||
* Reads the authenticated owner's Clerk userId and provides the Prisma client.
|
||||
*
|
||||
* `locale` mirrors the NEXT_LOCALE cookie resolution in i18n/request.ts, so
|
||||
* procedures that return DB-stored, translatable text (Species/Breed names —
|
||||
* see SpeciesTranslation/BreedTranslation) can resolve the right language
|
||||
* without every caller having to pass a locale input param explicitly.
|
||||
*/
|
||||
export const createTRPCContext = async () => {
|
||||
const { userId } = await auth();
|
||||
return { userId, prisma };
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get("NEXT_LOCALE")?.value ?? "";
|
||||
const locale = (LOCALES as readonly string[]).includes(raw) ? raw : defaultLocale;
|
||||
return { userId, prisma, locale };
|
||||
};
|
||||
|
||||
type Context = Awaited<ReturnType<typeof createTRPCContext>>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, publicProcedure } from "@/trpc/init";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h
|
||||
const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
@@ -25,14 +26,20 @@ export const exploreRouter = router({
|
||||
take: input.limit + 1,
|
||||
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
_count: { select: { followers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasNextPage = pets.length > input.limit;
|
||||
const items = hasNextPage ? pets.slice(0, input.limit) : pets;
|
||||
const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
}));
|
||||
return {
|
||||
pets: items,
|
||||
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { backfillOnFollow, fanOutPost } from "@/lib/feed-helpers";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { FOLLOWER_MILESTONE_THRESHOLDS as FOLLOWER_MILESTONES } from "@/lib/milestone-thresholds";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
/**
|
||||
* Follows tRPC router — pet-to-pet social graph (SOCL-01, SOCL-02, SOCL-03).
|
||||
@@ -173,7 +174,7 @@ export const followsRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
avatarKey: true,
|
||||
species: { select: { name: true } },
|
||||
species: { select: { name: true, translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -182,7 +183,16 @@ export const followsRouter = router({
|
||||
});
|
||||
|
||||
const hasNextPage = rows.length > input.limit;
|
||||
const items = hasNextPage ? rows.slice(0, input.limit) : rows;
|
||||
const items = (hasNextPage ? rows.slice(0, input.limit) : rows).map((row) => ({
|
||||
...row,
|
||||
follower: {
|
||||
...row.follower,
|
||||
species: {
|
||||
...row.follower.species,
|
||||
name: pickLocalizedName(row.follower.species.name, row.follower.species.translations, ctx.locale),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const nextCursor = hasNextPage
|
||||
? items[items.length - 1]!.createdAt.toISOString()
|
||||
: null;
|
||||
@@ -241,7 +251,7 @@ export const followsRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
avatarKey: true,
|
||||
species: { select: { name: true } },
|
||||
species: { select: { name: true, translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -250,7 +260,16 @@ export const followsRouter = router({
|
||||
});
|
||||
|
||||
const hasNextPage = rows.length > input.limit;
|
||||
const items = hasNextPage ? rows.slice(0, input.limit) : rows;
|
||||
const items = (hasNextPage ? rows.slice(0, input.limit) : rows).map((row) => ({
|
||||
...row,
|
||||
followee: {
|
||||
...row.followee,
|
||||
species: {
|
||||
...row.followee.species,
|
||||
name: pickLocalizedName(row.followee.species.name, row.followee.species.translations, ctx.locale),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const nextCursor = hasNextPage
|
||||
? items[items.length - 1]!.createdAt.toISOString()
|
||||
: null;
|
||||
|
||||
+43
-10
@@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
|
||||
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
export const petsRouter = router({
|
||||
/**
|
||||
@@ -60,14 +61,21 @@ export const petsRouter = router({
|
||||
* Scoped by ownerId — never returns other owners' pets.
|
||||
*/
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return ctx.prisma.pet.findMany({
|
||||
const pets = await ctx.prisma.pet.findMany({
|
||||
where: { ownerId: ctx.userId },
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return pets.map((pet) => ({
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -79,7 +87,10 @@ export const petsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const pet = await ctx.prisma.pet.findUnique({
|
||||
where: { id: input.petId },
|
||||
include: { species: true, breed: true },
|
||||
include: {
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!pet || pet.ownerId !== ctx.userId) {
|
||||
@@ -89,7 +100,13 @@ export const petsRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
return pet;
|
||||
return {
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -102,10 +119,19 @@ export const petsRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const pet = await ctx.prisma.pet.findUnique({
|
||||
where: { id: input.petId },
|
||||
include: { species: true, breed: true },
|
||||
include: {
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
});
|
||||
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return pet;
|
||||
return {
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -144,14 +170,21 @@ export const petsRouter = router({
|
||||
if (data.adoptedAt !== undefined)
|
||||
updateData.adoptedAt = data.adoptedAt ? new Date(data.adoptedAt) : null;
|
||||
|
||||
return ctx.prisma.pet.update({
|
||||
const pet = await ctx.prisma.pet.update({
|
||||
where: { id: petId },
|
||||
data: updateData,
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { pickLocalizedName } from "@/lib/localized-name";
|
||||
|
||||
export const searchRouter = router({
|
||||
pets: protectedProcedure
|
||||
@@ -24,14 +25,20 @@ export const searchRouter = router({
|
||||
orderBy: { name: "asc" },
|
||||
take: input.limit + 1,
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
_count: { select: { followers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasNextPage = pets.length > input.limit;
|
||||
const items = hasNextPage ? pets.slice(0, input.limit) : pets;
|
||||
const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
}));
|
||||
return {
|
||||
pets: items,
|
||||
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
|
||||
@@ -103,8 +110,8 @@ export const searchRouter = router({
|
||||
include: {
|
||||
pets: {
|
||||
include: {
|
||||
species: true,
|
||||
breed: true,
|
||||
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
||||
_count: { select: { followers: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
@@ -123,7 +130,13 @@ export const searchRouter = router({
|
||||
firstName: cu.firstName ?? null,
|
||||
lastName: cu.lastName ?? null,
|
||||
imageUrl: cu.imageUrl ?? null,
|
||||
pets: owner.pets,
|
||||
pets: owner.pets.map((pet) => ({
|
||||
...pet,
|
||||
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
||||
breed: pet.breed
|
||||
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
||||
: pet.breed,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user