feat(i18n): add EN/DE language support with next-intl

- Install next-intl@4 with cookie-based locale switching (NEXT_LOCALE)
- Add LanguageSwitcher component in Sidebar footer
- Translate Nav, PostTypeSheet, Feed, Explore, WelcomeCard, PetForm,
  AvatarUpload, and Onboarding pages (EN + DE)
- Add SpeciesTranslation and BreedTranslation DB models for locale-aware
  taxonomy; listSpecies and listBreeds accept optional locale param
- Seed German translations for all 3 species and 45 breeds
- Add SQL migration script for Supabase (add_i18n_translations.sql)
- fix(invite): secure cookie flag and set-invite route for HTTPS
This commit is contained in:
admin committed 2026-06-22 14:01:16 +02:00
1 parent 8645421c1e
commit 4b17537a9a
25 files changed
+1416 -208

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+106
View File
@@ -0,0 +1,106 @@
{
"Nav": {
"home": "Startseite",
"myPets": "Meine Tiere",
"explore": "Entdecken",
"search": "Suchen",
"notifications": "Benachrichtigungen",
"messages": "Nachrichten",
"invite": "Einladen",
"newPost": "Beitrag erstellen",
"addPet": "Tier hinzufügen",
"account": "Konto",
"profile": "Profil"
},
"PostType": {
"title": "Erstellen",
"photo": "Foto-Beitrag",
"story": "Story",
"video": "Video-Beitrag",
"milestone": "Meilenstein",
"newPost": "Neuer Beitrag",
"newStory": "Neue Story",
"newMilestone": "Neuer Meilenstein",
"newVideo": "Neues Video"
},
"Feed": {
"emptyTitle": "Dein Feed ist leer",
"emptyBody": "Folge anderen Tieren, um ihre Beiträge hier zu sehen.",
"findPets": "Tiere entdecken"
},
"Welcome": {
"title": "Willkommen bei PawFeed!",
"subtitle": "Das kann dein Tier alles machen:",
"close": "Schließen",
"understood": "Verstanden",
"ctaHint": "Drücke + in der Navigation, um deinen ersten Beitrag zu erstellen.",
"photosTitle": "Fotos & Videos",
"photosDesc": "Teile Momente aus dem Leben deines Tieres — Einzelbilder, Karussells oder Kurzvideos.",
"storiesTitle": "Stories",
"storiesDesc": "24-Stunden-Stories verschwinden automatisch. Sieh, wer deine Story gesehen hat.",
"milestonesTitle": "Meilensteine",
"milestonesDesc": "Feiere Geburtstage, Adoptions-Jahrestage, erste Ausflüge und mehr.",
"dmsTitle": "Direktnachrichten",
"dmsDesc": "Schreibe anderen Tieren direkt — perfekt für Spielverabredungen oder Fragen.",
"healthTitle": "Gesundheitsakte",
"healthDesc": "Verfolge Gewicht, Impfungen und Tierarztbesuche. Teile die Gesundheitskarte mit deinem Tierarzt."
},
"Explore": {
"title": "Entdecken",
"trending": "Trending",
"all": "Alle",
"allBreeds": "Alle Rassen",
"loadMore": "Mehr laden",
"noPetsYet": "Noch keine {species}",
"noTrending": "Noch keine Trending-Inhalte — schau später wieder rein",
"trendingHashtags": "Trending-Hashtags",
"hotPosts": "Beliebte Beiträge · letzte 48 Std."
},
"PetForm": {
"name": "Name des Tieres",
"namePlaceholder": "Name eingeben",
"nameRequired": "Dieses Feld ist erforderlich.",
"nameMaxLength": "Der Name darf maximal 30 Zeichen lang sein.",
"species": "Tierart",
"selectSpecies": "Tierart auswählen",
"speciesRequired": "Bitte wähle eine Tierart aus.",
"breed": "Rasse",
"selectBreed": "Rasse auswählen (optional)",
"selectSpeciesFirst": "Wähle zuerst eine Tierart aus",
"noBreedsFound": "Keine Rassen gefunden.",
"bio": "Bio",
"adoptionStory": "Adoptionsgeschichte",
"birthday": "Geburtstag",
"birthdayHint": "Für Geburtstags-Erinnerungen",
"adoptedAt": "Adoptionsdatum",
"adoptedAtHint": "Für Jahrestags-Erinnerungen",
"addPet": "Tier anlegen",
"saveChanges": "Änderungen speichern",
"petCreated": "Tierprofil erstellt",
"changesSaved": "Änderungen gespeichert",
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut."
},
"AvatarUpload": {
"uploadPhoto": "Foto hochladen",
"changePhoto": "Foto ändern",
"savePetFirst": "Speichere dein Tier zuerst, um ein Foto hochzuladen.",
"uploadFailed": "Upload fehlgeschlagen — bitte versuche es erneut",
"photoUpdated": "Profilfoto aktualisiert",
"fileTooLarge": "Das Foto muss kleiner als 10 MB sein.",
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
"uploading": "Wird hochgeladen…"
},
"Onboarding": {
"addFirstPet": "Füge dein erstes Tier hinzu",
"addFirstPetDesc": "Dein Tier wird sein eigener Star auf PawFeed.",
"addPhoto": "Profilfoto hinzufügen",
"addPhotoDesc": "Gib deinem Tier ein Gesicht auf PawFeed. Du kannst das jederzeit ändern.",
"done": "Fertig",
"skipForNow": "Jetzt überspringen"
},
"Language": {
"switch": "Sprache",
"en": "English",
"de": "Deutsch"
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"Nav": {
"home": "Home",
"myPets": "My Pets",
"explore": "Explore",
"search": "Search",
"notifications": "Notifications",
"messages": "Messages",
"invite": "Invite Friends",
"newPost": "New Post",
"addPet": "Add Pet",
"account": "Account",
"profile": "Profile"
},
"PostType": {
"title": "Create",
"photo": "Photo Post",
"story": "Story",
"video": "Video Post",
"milestone": "Milestone",
"newPost": "New Post",
"newStory": "New Story",
"newMilestone": "New Milestone",
"newVideo": "New Video"
},
"Feed": {
"emptyTitle": "Your feed is empty",
"emptyBody": "Follow some pets to see their posts here.",
"findPets": "Find Pets"
},
"Welcome": {
"title": "Welcome to PawFeed!",
"subtitle": "Here's what your pet can do:",
"close": "Close",
"understood": "Got it",
"ctaHint": "Press + in the nav to create your first post.",
"photosTitle": "Photos & Videos",
"photosDesc": "Share moments from your pet's life — single images, carousels, or short videos.",
"storiesTitle": "Stories",
"storiesDesc": "24 h stories disappear automatically. See who viewed your story.",
"milestonesTitle": "Milestones",
"milestonesDesc": "Celebrate birthdays, adoption anniversaries, first outings and more.",
"dmsTitle": "DMs",
"dmsDesc": "Message other pets directly — perfect for playdates or questions.",
"healthTitle": "Health Tracking",
"healthDesc": "Track weight, vaccinations and vet visits. Share the Health Card with your vet."
},
"Explore": {
"title": "Explore",
"trending": "Trending",
"all": "All",
"allBreeds": "All breeds",
"loadMore": "Load more",
"noPetsYet": "No {species} pets yet",
"noTrending": "No trending content yet — check back soon",
"trendingHashtags": "Trending hashtags",
"hotPosts": "Hot posts · last 48 h"
},
"PetForm": {
"name": "Pet name",
"namePlaceholder": "Enter pet name",
"nameRequired": "This field is required.",
"nameMaxLength": "Pet name must be 30 characters or fewer.",
"species": "Species",
"selectSpecies": "Select species",
"speciesRequired": "Please select a species.",
"breed": "Breed",
"selectBreed": "Select breed (optional)",
"selectSpeciesFirst": "Select a species first",
"noBreedsFound": "No breeds found.",
"bio": "Bio",
"adoptionStory": "Adoption story",
"birthday": "Birthday",
"birthdayHint": "For birthday reminders",
"adoptedAt": "Adoption date",
"adoptedAtHint": "For anniversary reminders",
"addPet": "Add Pet",
"saveChanges": "Save changes",
"petCreated": "Pet profile created",
"changesSaved": "Changes saved",
"error": "Something went wrong. Please try again."
},
"AvatarUpload": {
"uploadPhoto": "Upload photo",
"changePhoto": "Change photo",
"savePetFirst": "Save your pet first to upload a photo.",
"uploadFailed": "Upload failed — please try again",
"photoUpdated": "Profile photo updated",
"fileTooLarge": "Photo must be under 10 MB.",
"invalidType": "Upload a JPG, PNG, or WebP image.",
"uploading": "Uploading…"
},
"Onboarding": {
"addFirstPet": "Add your first pet",
"addFirstPetDesc": "Your pet will be their own star on PawFeed.",
"addPhoto": "Add a profile photo",
"addPhotoDesc": "Give your pet a face on PawFeed. You can always change this later.",
"done": "Done",
"skipForNow": "Skip for now"
},
"Language": {
"switch": "Language",
"en": "English",
"de": "Deutsch"
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
function getSupabaseHostname(): string {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -18,4 +21,4 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);
+697 -1
View File
@@ -30,6 +30,7 @@
"ioredis": "^5.11.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-intl": "^4.13.0",
"next-themes": "^0.4.6",
"pg": "^8.21.0",
"prisma": "^7.8.0",
@@ -2203,6 +2204,36 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.11",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz",
"integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.10"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz",
"integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz",
"integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.6"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
@@ -3351,6 +3382,313 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
@@ -3992,6 +4330,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@schummar/icu-type-parser": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -4232,6 +4576,204 @@
"node": ">=20.0.0"
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz",
"integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz",
"integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz",
"integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz",
"integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz",
"integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz",
"integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz",
"integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==",
"cpu": [
"s390x"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz",
"integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz",
"integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz",
"integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz",
"integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz",
"integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4241,6 +4783,15 @@
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -6887,7 +7438,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -8819,6 +9369,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/icu-minify": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz",
"integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -8875,6 +9440,16 @@
"node": ">= 0.4"
}
},
"node_modules/intl-messageformat": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz",
"integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.6",
"@formatjs/icu-messageformat-parser": "3.5.11"
}
},
"node_modules/ioredis": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
@@ -10596,6 +11171,94 @@
}
}
},
"node_modules/next-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz",
"integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.8.1",
"@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2",
"icu-minify": "^4.13.0",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.13.0",
"po-parser": "^2.1.1",
"use-intl": "^4.13.0"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/next-intl-swc-plugin-extractor": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz",
"integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==",
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.41",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz",
"integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.41",
"@swc/core-darwin-x64": "1.15.41",
"@swc/core-linux-arm-gnueabihf": "1.15.41",
"@swc/core-linux-arm64-gnu": "1.15.41",
"@swc/core-linux-arm64-musl": "1.15.41",
"@swc/core-linux-ppc64-gnu": "1.15.41",
"@swc/core-linux-s390x-gnu": "1.15.41",
"@swc/core-linux-x64-gnu": "1.15.41",
"@swc/core-linux-x64-musl": "1.15.41",
"@swc/core-win32-arm64-msvc": "1.15.41",
"@swc/core-win32-ia32-msvc": "1.15.41",
"@swc/core-win32-x64-msvc": "1.15.41"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -10634,6 +11297,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -11351,6 +12020,12 @@
"media-chrome": "~4.19.0"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
"integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13538,6 +14213,27 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz",
"integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^3.1.0",
"@schummar/icu-type-parser": "1.21.5",
"icu-minify": "^4.13.0",
"intl-messageformat": "^11.1.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+1
View File
@@ -34,6 +34,7 @@
"ioredis": "^5.11.1",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-intl": "^4.13.0",
"next-themes": "^0.4.6",
"pg": "^8.21.0",
"prisma": "^7.8.0",
@@ -0,0 +1,30 @@
-- Migration: add_i18n_translations
-- Creates SpeciesTranslation and BreedTranslation tables for locale-aware taxonomy.
-- Run this directly on Supabase SQL Editor.
CREATE TABLE IF NOT EXISTS "SpeciesTranslation" (
"id" TEXT NOT NULL,
"speciesId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "SpeciesTranslation_pkey" PRIMARY KEY ("id"),
CONSTRAINT "SpeciesTranslation_speciesId_locale_key" UNIQUE ("speciesId", "locale"),
CONSTRAINT "SpeciesTranslation_speciesId_fkey"
FOREIGN KEY ("speciesId") REFERENCES "Species"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "BreedTranslation" (
"id" TEXT NOT NULL,
"breedId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "BreedTranslation_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BreedTranslation_breedId_locale_key" UNIQUE ("breedId", "locale"),
CONSTRAINT "BreedTranslation_breedId_fkey"
FOREIGN KEY ("breedId") REFERENCES "Breed"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "SpeciesTranslation_speciesId_idx" ON "SpeciesTranslation"("speciesId");
CREATE INDEX IF NOT EXISTS "BreedTranslation_breedId_idx" ON "BreedTranslation"("breedId");
+31 -9
View File
@@ -31,22 +31,44 @@ model InviteCode {
}
model Species {
id String @id @default(cuid())
name String @unique // "Dog" | "Cat" | "Bird"
breeds Breed[]
pets Pet[]
id String @id @default(cuid())
name String @unique
breeds Breed[]
pets Pet[]
translations SpeciesTranslation[]
}
model SpeciesTranslation {
id String @id @default(cuid())
species Species @relation(fields: [speciesId], references: [id])
speciesId String
locale String
name String
@@unique([speciesId, locale])
}
model Breed {
id String @id @default(cuid())
name String
species Species @relation(fields: [speciesId], references: [id])
speciesId String
pets Pet[]
id String @id @default(cuid())
name String
species Species @relation(fields: [speciesId], references: [id])
speciesId String
pets Pet[]
translations BreedTranslation[]
@@unique([speciesId, name])
}
model BreedTranslation {
id String @id @default(cuid())
breed Breed @relation(fields: [breedId], references: [id])
breedId String
locale String
name String
@@unique([breedId, locale])
}
model Pet {
id String @id @default(cuid())
owner Owner @relation(fields: [ownerId], references: [id])
+87
View File
@@ -75,6 +75,72 @@ const BIRD_BREEDS = [
"Amazon Parrot",
];
const SPECIES_DE: Record<string, string> = {
"Dog": "Hund",
"Cat": "Katze",
"Bird": "Vogel",
};
const DOG_BREEDS_DE: Record<string, string> = {
"Labrador Retriever": "Labrador Retriever",
"Golden Retriever": "Golden Retriever",
"German Shepherd": "Deutscher Schäferhund",
"French Bulldog": "Französische Bulldogge",
"Bulldog": "Bulldogge",
"Poodle": "Pudel",
"Beagle": "Beagle",
"Rottweiler": "Rottweiler",
"German Shorthaired Pointer": "Deutsch Kurzhaar",
"Dachshund": "Dackel",
"Pembroke Welsh Corgi": "Pembroke Welsh Corgi",
"Australian Shepherd": "Australian Shepherd",
"Yorkshire Terrier": "Yorkshire Terrier",
"Boxer": "Boxer",
"Cavalier King Charles Spaniel": "Cavalier King Charles Spaniel",
"Doberman Pinscher": "Dobermann",
"Great Dane": "Deutsche Dogge",
"Miniature Schnauzer": "Zwergschnauzer",
"Siberian Husky": "Sibirischer Husky",
"Border Collie": "Border Collie",
};
const CAT_BREEDS_DE: Record<string, string> = {
"Domestic Shorthair": "Europäisch Kurzhaar",
"Domestic Longhair": "Europäisch Langhaar",
"Maine Coon": "Maine Coon",
"Ragdoll": "Ragdoll",
"Siamese": "Siamkatze",
"Persian": "Perserkatze",
"Bengal": "Bengalkatze",
"British Shorthair": "Britisch Kurzhaar",
"Sphynx": "Sphynx",
"Scottish Fold": "Scottish Fold",
"American Shorthair": "Amerikanisch Kurzhaar",
"Abyssinian": "Abessinier",
"Norwegian Forest Cat": "Norwegische Waldkatze",
"Birman": "Heilige Birma",
"Russian Blue": "Russisch Blau",
};
const BIRD_BREEDS_DE: Record<string, string> = {
"Budgerigar (Budgie)": "Wellensittich",
"Cockatiel": "Nymphensittich",
"Lovebird": "Unzertrennliche",
"African Grey Parrot": "Graupapagei",
"Cockatoo": "Kakadu",
"Conure": "Sittich",
"Macaw": "Ara",
"Canary": "Kanarienvogel",
"Finch": "Fink",
"Amazon Parrot": "Amazone",
};
const BREED_DE_BY_SPECIES: Record<string, Record<string, string>> = {
"Dog": DOG_BREEDS_DE,
"Cat": CAT_BREEDS_DE,
"Bird": BIRD_BREEDS_DE,
};
async function main() {
console.log("Seeding species...");
@@ -92,13 +158,34 @@ async function main() {
});
console.log(` Upserted species: ${species.name} (${species.id})`);
const deSpeciesName = SPECIES_DE[s.name];
if (deSpeciesName) {
await prisma.speciesTranslation.upsert({
where: { speciesId_locale: { speciesId: species.id, locale: "de" } },
update: { name: deSpeciesName },
create: { speciesId: species.id, locale: "de", name: deSpeciesName },
});
}
console.log(` Seeding ${s.breeds.length} breeds for ${species.name}...`);
const breedDeMap = BREED_DE_BY_SPECIES[s.name] ?? {};
for (const breedName of s.breeds) {
const breed = await prisma.breed.upsert({
where: { speciesId_name: { speciesId: species.id, name: breedName } },
update: {},
create: { speciesId: species.id, name: breedName },
});
const deBreedName = breedDeMap[breedName];
if (deBreedName) {
await prisma.breedTranslation.upsert({
where: { breedId_locale: { breedId: breed.id, locale: "de" } },
update: { name: deBreedName },
create: { breedId: breed.id, locale: "de", name: deBreedName },
});
}
console.log(` Upserted breed: ${breed.name}`);
}
}
+23 -19
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { useTranslations, useLocale } from "next-intl";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Loader2, TrendingUp } from "lucide-react";
import { useTRPC } from "@/trpc/client";
@@ -23,6 +24,8 @@ import {
import { Button } from "@/components/ui/button";
export default function ExplorePage() {
const t = useTranslations("Explore");
const locale = useLocale();
const trpc = useTRPC();
const { activePetId } = useActivePet();
@@ -35,12 +38,12 @@ export default function ExplorePage() {
activeTab === "trending" || activeTab === "all" ? null : activeTab;
const { data: speciesList, isLoading: loadingSpecies } = useQuery(
trpc.pets.listSpecies.queryOptions()
trpc.pets.listSpecies.queryOptions({ locale })
);
const { data: breedData } = useQuery(
trpc.pets.listBreeds.queryOptions(
{ speciesId: activeSpeciesId ?? "" },
{ speciesId: activeSpeciesId ?? "", locale },
{ enabled: !!activeSpeciesId }
)
);
@@ -88,15 +91,15 @@ export default function ExplorePage() {
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Explore</h1>
<h1 className="text-xl font-semibold mb-4">{t("title")}</h1>
<Tabs value={activeTab} onValueChange={handleTabChange} className="mb-4">
<TabsList>
<TabsTrigger value="trending" className="gap-1">
<TrendingUp className="h-3.5 w-3.5" />
Trending
{t("trending")}
</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="all">{t("all")}</TabsTrigger>
{(speciesList ?? []).map((s) => (
<TabsTrigger key={s.id} value={s.id}>
{s.name}
@@ -113,10 +116,10 @@ export default function ExplorePage() {
onValueChange={(v) => setActiveBreedId(v === "all" ? null : v)}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All breeds" />
<SelectValue placeholder={t("allBreeds")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All breeds</SelectItem>
<SelectItem value="all">{t("allBreeds")}</SelectItem>
{breeds.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
@@ -144,14 +147,13 @@ export default function ExplorePage() {
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No{" "}
{activeBreedId
? (breeds.find((b) => b.id === activeBreedId)?.name ?? "breed")
: activeSpeciesId
? ((speciesList ?? []).find((s) => s.id === activeSpeciesId)
?.name ?? "species")
: ""}{" "}
pets yet
{t("noPetsYet", {
species: activeBreedId
? (breeds.find((b) => b.id === activeBreedId)?.name ?? "")
: activeSpeciesId
? ((speciesList ?? []).find((s) => s.id === activeSpeciesId)?.name ?? "")
: "",
})}
</p>
) : (
<>
@@ -175,7 +177,7 @@ export default function ExplorePage() {
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Load more
{t("loadMore")}
</Button>
</div>
)}
@@ -239,6 +241,8 @@ function TrendingContent({
isLoading: boolean;
onSelectPost: (post: PostCardPost) => void;
}) {
const t = useTranslations("Explore");
if (isLoading) {
return (
<div className="flex justify-center py-16">
@@ -254,7 +258,7 @@ function TrendingContent({
if (!hasContent) {
return (
<p className="text-sm text-muted-foreground text-center py-10">
No trending content yet — check back soon
{t("noTrending")}
</p>
);
}
@@ -265,7 +269,7 @@ function TrendingContent({
{hashtags.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Trending hashtags
{t("trendingHashtags")}
</h2>
<div className="flex flex-wrap gap-2">
{hashtags.map((ht) => (
@@ -289,7 +293,7 @@ function TrendingContent({
{posts.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Hot posts · last 48 h
{t("hotPosts")}
</h2>
<div className="grid grid-cols-3 gap-0.5">
{posts.map((post) => {
+20 -5
View File
@@ -1,22 +1,37 @@
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
export async function GET() {
function resolveBase(request: NextRequest): string {
const proto = request.headers.get("x-forwarded-proto") ?? "https";
const host =
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
"localhost:3000";
return `${proto}://${host}`;
}
export async function GET(request: NextRequest) {
const { userId } = await auth();
if (!userId) redirect("/log-in");
const base = resolveBase(request);
if (!userId) {
return NextResponse.redirect(new URL("/log-in", base));
}
const cookieStore = await cookies();
const inviteCode = cookieStore.get("pf_invite")?.value;
const response = NextResponse.redirect(new URL("/", base));
if (inviteCode) {
await consumeInviteCode(userId, inviteCode);
cookieStore.delete("pf_invite");
response.cookies.delete("pf_invite");
}
redirect("/");
return response;
}
async function consumeInviteCode(userId: string, code: string) {
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
function resolveBase(request: NextRequest): string {
const proto = request.headers.get("x-forwarded-proto") ?? "https";
const host =
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
"localhost:3000";
return `${proto}://${host}`;
}
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code")?.trim().toUpperCase();
const base = resolveBase(request);
if (!code) {
return NextResponse.redirect(new URL("/join?error=missing", base));
}
const invite = await prisma.inviteCode.findUnique({ where: { code } });
if (!invite || invite.usedById || invite.revoked) {
return NextResponse.redirect(new URL("/join?error=invalid", base));
}
const response = NextResponse.redirect(new URL("/sign-up", base));
response.cookies.set("pf_invite", code, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60,
path: "/",
});
return response;
}
+9 -22
View File
@@ -1,7 +1,5 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
interface JoinPageProps {
@@ -23,7 +21,7 @@ async function submitCode(formData: FormData) {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60, // 1 hour
maxAge: 60 * 60,
path: "/",
});
redirect("/sign-up");
@@ -32,28 +30,17 @@ async function submitCode(formData: FormData) {
export default async function JoinPage({ searchParams }: JoinPageProps) {
const { code: codeParam, error } = await searchParams;
// If a code is in the URL, pre-validate and set cookie immediately
// Code in URL → hand off to route handler which can set cookies
if (codeParam) {
const code = codeParam.trim().toUpperCase();
const invite = await prisma.inviteCode.findUnique({ where: { code } });
if (invite && !invite.usedById && !invite.revoked) {
const cookieStore = await cookies();
cookieStore.set("pf_invite", code, {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60,
path: "/",
});
redirect("/sign-up");
}
// invalid code in URL — fall through and show form with error
redirect(`/api/auth/set-invite?code=${encodeURIComponent(codeParam)}`);
}
const errorMessage =
error === "invalid" ? "Ungültiger oder bereits verwendeter Einladungscode."
: error === "missing" ? "Bitte gib einen Einladungscode ein."
: codeParam ? "Dieser Einladungscode ist ungültig oder wurde bereits verwendet."
: null;
error === "invalid"
? "Ungültiger oder bereits verwendeter Einladungscode."
: error === "missing"
? "Bitte gib einen Einladungscode ein."
: null;
return (
<main className="min-h-screen flex items-center justify-center bg-zinc-950 px-4">
+15 -8
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { TRPCReactProvider } from "@/trpc/client";
import { ActivePetProvider } from "@/context/ActivePetContext";
import { Toaster } from "@/components/ui/sonner";
@@ -16,11 +18,14 @@ export const metadata: Metadata = {
description: "The social platform where pets are the stars.",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
const messages = await getMessages();
return (
<ClerkProvider
appearance={{
@@ -31,14 +36,16 @@ export default function RootLayout({
},
}}
>
<html lang="en" className={`${inter.variable} h-full antialiased font-sans`}>
<html lang={locale} className={`${inter.variable} h-full antialiased font-sans`}>
<body className="min-h-full flex flex-col">
<TRPCReactProvider>
<ActivePetProvider>
{children}
<Toaster />
</ActivePetProvider>
</TRPCReactProvider>
<NextIntlClientProvider locale={locale} messages={messages}>
<TRPCReactProvider>
<ActivePetProvider>
{children}
<Toaster />
</ActivePetProvider>
</TRPCReactProvider>
</NextIntlClientProvider>
</body>
</html>
</ClerkProvider>
+44 -12
View File
@@ -1,33 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PetForm } from "@/components/pet/PetForm";
import { AvatarUpload } from "@/components/pet/AvatarUpload";
import { Button } from "@/components/ui/button";
/**
* Onboarding pet page — /onboarding/pet
*
* Shown when an authenticated owner has no pets yet (redirected from app layout).
* Lives OUTSIDE the (app) layout group to avoid redirect loop:
* app layout sees 0 pets → redirects here → this page has no pet check.
* Two-step flow:
* Step 1: Create pet profile (name, species, etc.)
* Step 2: Upload avatar photo (now that petId exists)
*
* On successful pet creation, redirects to /pets/[petId] (the new pet's profile).
*
* This is a client component because onSuccess uses router.push.
* Lives OUTSIDE the (app) layout group to avoid redirect loop.
*/
export default function OnboardingPetPage() {
const router = useRouter();
const t = useTranslations("Onboarding");
const [createdPetId, setCreatedPetId] = useState<string | null>(null);
if (createdPetId) {
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-[512px] p-8 flex flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-xl font-semibold mb-2">{t("addPhoto")}</h1>
<p className="text-sm text-muted-foreground">{t("addPhotoDesc")}</p>
</div>
<AvatarUpload petId={createdPetId} onUploadComplete={() => {}} />
<div className="flex flex-col gap-2 w-full">
<Button
className="w-full"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("done")}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => router.push(`/pets/${createdPetId}`)}
>
{t("skipForNow")}
</Button>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-[512px] p-8">
<h1 className="text-xl font-semibold mb-2">Add your first pet</h1>
<p className="text-sm text-muted-foreground mb-8">
Your pet will be their own star on PawFeed.
</p>
<h1 className="text-xl font-semibold mb-2">{t("addFirstPet")}</h1>
<p className="text-sm text-muted-foreground mb-8">{t("addFirstPetDesc")}</p>
<PetForm
mode="create"
onSuccess={(petId) => router.push(`/pets/${petId}`)}
onSuccess={(petId) => setCreatedPetId(petId)}
/>
</div>
</main>
+7 -21
View File
@@ -1,37 +1,23 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { buttonVariants } from "@/components/ui/button";
/**
* EmptyFeed — shown when the active pet's feed has zero posts.
*
* D-06: pets with no follows land on feed with this empty state.
* UI-SPEC copy (locked):
* - Heading: "Your feed is empty"
* - Body: "Follow some pets to see their posts here."
* - CTA: "Find Pets" → /explore (Phase 3 route — placeholder button)
*
* Note: shadcn base-nova uses @base-ui/react/button which does not support asChild.
* Using Link with buttonVariants CSS classes directly.
*/
export function EmptyFeed() {
export async function EmptyFeed() {
const t = await getTranslations("Feed");
return (
<div className="flex flex-col items-center justify-center py-20 px-6 text-center">
{/* Heading — text-xl font-semibold per UI-SPEC typography */}
<h2 className="text-xl font-semibold text-foreground">
Your feed is empty
{t("emptyTitle")}
</h2>
{/* Body — text-sm per UI-SPEC */}
<p className="mt-3 text-sm text-muted-foreground max-w-xs">
Follow some pets to see their posts here.
{t("emptyBody")}
</p>
{/* Find Pets CTA — primary button style, links to /explore (Phase 3 placeholder) */}
<Link
href="/explore"
className={buttonVariants({ variant: "default" }) + " mt-6 bg-orange-500 hover:bg-orange-600 text-white border-transparent"}
>
Find Pets
{t("findPets")}
</Link>
</div>
);
+17 -39
View File
@@ -1,38 +1,20 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { X, Camera, Flame, Heart, MessageCircle, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
const FEATURES = [
{
icon: Camera,
title: "Fotos & Videos",
desc: "Teile Momente aus dem Leben deines Haustieres — Einzelbilder, Karussells oder Kurzvideos.",
},
{
icon: Flame,
title: "Stories",
desc: "24h-Storys verschwinden automatisch. Sieh, wer deine Story angeschaut hat.",
},
{
icon: Heart,
title: "Meilensteine",
desc: "Feiere Geburtstage, Adoptions-Jahrestage, erste Ausflüge und mehr.",
},
{
icon: MessageCircle,
title: "DMs",
desc: "Schreibe anderen Pets direkt — perfekt für Spielverabredungen oder Fragen.",
},
{
icon: Activity,
title: "Health Tracking",
desc: "Verfolge Gewicht, Impfungen und Tierarztbesuche. Teile die Health Card mit dem Tierarzt.",
},
const FEATURE_DEFS = [
{ icon: Camera, titleKey: "photosTitle", descKey: "photosDesc" },
{ icon: Flame, titleKey: "storiesTitle", descKey: "storiesDesc" },
{ icon: Heart, titleKey: "milestonesTitle", descKey: "milestonesDesc" },
{ icon: MessageCircle, titleKey: "dmsTitle", descKey: "dmsDesc" },
{ icon: Activity, titleKey: "healthTitle", descKey: "healthDesc" },
] as const;
export function WelcomeCard({ petId }: { petId: string }) {
const t = useTranslations("Welcome");
const storageKey = `pf_welcome_seen_${petId}`;
const [visible, setVisible] = useState(false);
@@ -54,16 +36,14 @@ export function WelcomeCard({ petId }: { petId: string }) {
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-orange-500 text-lg">🐾</span>
<h2 className="text-base font-semibold">Willkommen bei PawFeed!</h2>
<h2 className="text-base font-semibold">{t("title")}</h2>
</div>
<p className="text-sm text-muted-foreground">
Hier ist, was dein Haustier alles tun kann:
</p>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<button
onClick={dismiss}
className="text-muted-foreground hover:text-foreground transition-colors mt-0.5"
aria-label="Schließen"
aria-label={t("close")}
>
<X className="h-4 w-4" />
</button>
@@ -71,14 +51,14 @@ export function WelcomeCard({ petId }: { petId: string }) {
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border mx-5 rounded-xl overflow-hidden border mb-4">
{FEATURES.map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex items-start gap-3 p-4 bg-background/80">
{FEATURE_DEFS.map(({ icon: Icon, titleKey, descKey }) => (
<div key={titleKey} className="flex items-start gap-3 p-4 bg-background/80">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-orange-500/10">
<Icon className="h-4 w-4 text-orange-500" />
</div>
<div>
<p className="text-sm font-medium leading-snug">{title}</p>
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{desc}</p>
<p className="text-sm font-medium leading-snug">{t(titleKey)}</p>
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{t(descKey)}</p>
</div>
</div>
))}
@@ -86,11 +66,9 @@ export function WelcomeCard({ petId }: { petId: string }) {
{/* CTA */}
<div className="flex items-center justify-between px-5 pb-5">
<p className="text-xs text-muted-foreground">
Drücke <span className="font-medium text-foreground">+</span> in der Navigation um deinen ersten Post zu erstellen.
</p>
<p className="text-xs text-muted-foreground">{t("ctaHint")}</p>
<Button size="sm" variant="ghost" onClick={dismiss} className="shrink-0 text-xs">
Verstanden
{t("understood")}
</Button>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { Globe } from "lucide-react";
const LOCALES = [
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
] as const;
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const t = useTranslations("Language");
const [isPending, startTransition] = useTransition();
function switchLocale(next: string) {
document.cookie = `NEXT_LOCALE=${next};path=/;max-age=31536000;samesite=lax`;
startTransition(() => {
router.refresh();
});
}
return (
<div className="flex items-center gap-1 px-2 py-1.5 rounded-md text-muted-foreground">
<Globe className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs">{t("switch")}:</span>
{LOCALES.map((l) => (
<button
key={l.code}
onClick={() => switchLocale(l.code)}
disabled={isPending || locale === l.code}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
locale === l.code
? "text-foreground font-medium bg-accent"
: "hover:text-foreground hover:bg-accent/60"
}`}
>
{l.label}
</button>
))}
</div>
);
}
+6 -4
View File
@@ -10,6 +10,7 @@ import {
User,
ChevronDown,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import {
@@ -55,6 +56,7 @@ export function MobileNav({ pets }: MobileNavProps) {
const router = useRouter();
const { activePetId, setActivePet } = useActivePet();
const t = useTranslations("Nav");
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
@@ -126,7 +128,7 @@ export function MobileNav({ pets }: MobileNavProps) {
className="flex items-center gap-2 cursor-pointer"
>
<span className="text-orange-500">+</span>
<span>Add Pet</span>
<span>{t("addPet")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -146,7 +148,7 @@ export function MobileNav({ pets }: MobileNavProps) {
}`}
>
<Home className="h-5 w-5" aria-hidden="true" />
<span>Home</span>
<span>{t("home")}</span>
</Link>
{/* Explore */}
@@ -157,7 +159,7 @@ export function MobileNav({ pets }: MobileNavProps) {
}`}
>
<Compass className="h-5 w-5" aria-hidden="true" />
<span>Explore</span>
<span>{t("explore")}</span>
</Link>
{/* Post — center, accent-filled. D-01: opens PostTypeSheet */}
@@ -183,7 +185,7 @@ export function MobileNav({ pets }: MobileNavProps) {
}`}
>
<User className="h-5 w-5" aria-hidden="true" />
<span>Profile</span>
<span>{t("profile")}</span>
</Link>
</nav>
+21 -12
View File
@@ -15,8 +15,10 @@ import {
Plus,
Ticket,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { LanguageSwitcher } from "@/components/i18n/LanguageSwitcher";
import { UserButton } from "@clerk/nextjs";
import {
DropdownMenu,
@@ -42,14 +44,14 @@ type SidebarProps = {
};
const navLinks = [
{ href: "/feed", label: "Home", icon: Home, active: true, special: false },
{ href: "/pets", label: "My Pets", icon: Heart, active: true, special: false },
{ href: "/explore", label: "Explore", icon: Compass, active: true, special: false },
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
{ href: "/messages", label: "Messages", icon: MessageCircle, active: true, special: false },
{ href: "/invite", label: "Einladen", icon: Ticket, active: true, special: false },
];
{ href: "/feed", key: "home", icon: Home, active: true, special: false },
{ href: "/pets", key: "myPets", icon: Heart, active: true, special: false },
{ href: "/explore", key: "explore", icon: Compass, active: true, special: false },
{ href: "/search", key: "search", icon: Search, active: true, special: false },
{ href: "/notifications", key: "notifications", icon: Bell, active: true, special: true },
{ href: "/messages", key: "messages", icon: MessageCircle, active: true, special: false },
{ href: "/invite", key: "invite", icon: Ticket, active: true, special: false },
] as const;
/**
* Desktop sidebar — 240px fixed left nav visible at lg: breakpoint.
@@ -71,6 +73,7 @@ const navLinks = [
export function Sidebar({ pets }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const t = useTranslations("Nav");
const { activePetId, setActivePet } = useActivePet();
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
@@ -144,7 +147,7 @@ export function Sidebar({ pets }: SidebarProps) {
className="flex items-center gap-2 cursor-pointer"
>
<span className="text-orange-500">+</span>
<span>Add Pet</span>
<span>{t("addPet")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -153,7 +156,8 @@ export function Sidebar({ pets }: SidebarProps) {
{/* Nav links */}
<div className="flex-1 px-3 py-2 space-y-1">
{navLinks.map(({ href, label, icon: Icon, active, special }) => {
{navLinks.map(({ href, key, icon: Icon, active, special }) => {
const label = t(key as Parameters<typeof t>[0]);
const isCurrentPage =
href === "/feed"
? pathname === "/feed" || pathname === "/"
@@ -205,10 +209,15 @@ export function Sidebar({ pets }: SidebarProps) {
aria-label="Create post"
>
<Plus className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>New Post</span>
<span>{t("newPost")}</span>
</button>
</div>
{/* Language switcher */}
<div className="px-1 pb-1">
<LanguageSwitcher />
</div>
{/* Legal mini-footer */}
<div className="px-4 pb-3 space-y-1">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/60">
@@ -232,7 +241,7 @@ export function Sidebar({ pets }: SidebarProps) {
{/* Bottom: owner identity */}
<div className="px-4 py-4 border-t border flex items-center gap-2">
<UserButton />
<span className="text-xs text-muted-foreground">Account</span>
<span className="text-xs text-muted-foreground">{t("account")}</span>
</div>
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
+14 -12
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Camera, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
@@ -48,6 +49,7 @@ export function AvatarUpload({
currentAvatarUrl,
onUploadComplete,
}: AvatarUploadProps) {
const t = useTranslations("AvatarUpload");
const trpc = useTRPC();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadState, setUploadState] = useState<UploadState>("idle");
@@ -78,19 +80,19 @@ export function AvatarUpload({
async (file: File) => {
// Disabled state guard — petId not yet available (create mode)
if (!petId) {
toast.info("Save your pet first to upload a photo.");
toast.info(t("savePetFirst"));
return;
}
// Client-side validation: file size
if (file.size > MAX_FILE_SIZE) {
handleError("Photo must be under 10 MB.");
handleError(t("fileTooLarge"));
return;
}
// Client-side validation: file type
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
handleError("Upload a JPG, PNG, or WebP image.");
handleError(t("invalidType"));
return;
}
@@ -147,9 +149,9 @@ export function AvatarUpload({
// Step 5: Notify parent and show success toast
onUploadComplete?.(key);
toast.success("Profile photo updated");
toast.success(t("photoUpdated"));
} catch {
handleError("Upload failed — please try again");
handleError(t("uploadFailed"));
setUploadState("error");
}
},
@@ -225,7 +227,7 @@ export function AvatarUpload({
className="flex flex-col items-center justify-center gap-2 rounded-full w-32 h-32 border-dashed border-2 border-muted opacity-50 cursor-not-allowed mx-auto"
>
<Camera className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<span className="text-xs text-muted-foreground">Upload photo</span>
<span className="text-xs text-muted-foreground">{t("uploadPhoto")}</span>
</div>
);
}
@@ -240,7 +242,7 @@ export function AvatarUpload({
{localAvatarUrl ? (
<AvatarImage
src={localAvatarUrl}
alt="Uploading..."
alt={t("uploading")}
className="opacity-50"
/>
) : (
@@ -262,7 +264,7 @@ export function AvatarUpload({
</div>
</div>
<span className="text-xs text-muted-foreground">
{uploadProgress > 0 ? `${uploadProgress}%` : "Uploading…"}
{uploadProgress > 0 ? `${uploadProgress}%` : t("uploading")}
</span>
</div>
);
@@ -288,7 +290,7 @@ export function AvatarUpload({
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
aria-label="Change profile photo"
aria-label={t("changePhoto")}
>
<Avatar className="w-32 h-32">
<AvatarImage src={localAvatarUrl!} alt="Pet avatar" />
@@ -299,7 +301,7 @@ export function AvatarUpload({
{/* Hover overlay */}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="h-6 w-6 text-white" aria-hidden="true" />
<span className="text-xs text-white font-medium">Change photo</span>
<span className="text-xs text-white font-medium">{t("changePhoto")}</span>
</div>
</div>
{uploadState === "error" && errorMessage && (
@@ -335,10 +337,10 @@ export function AvatarUpload({
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
aria-label="Upload profile photo"
aria-label={t("uploadPhoto")}
>
<Camera className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<span className="text-xs text-muted-foreground">Upload photo</span>
<span className="text-xs text-muted-foreground">{t("uploadPhoto")}</span>
</div>
{isError && errorMessage && (
<p className="text-xs text-destructive text-center max-w-[160px]">
+25 -22
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -73,6 +74,8 @@ export function PetForm({
initialBirthday,
initialAdoptedAt,
}: PetFormProps) {
const t = useTranslations("PetForm");
const locale = useLocale();
const trpc = useTRPC();
const queryClient = useQueryClient();
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string>(
@@ -84,13 +87,13 @@ export function PetForm({
);
const { data: speciesList = [], isLoading: speciesLoading } = useQuery({
...trpc.pets.listSpecies.queryOptions(),
...trpc.pets.listSpecies.queryOptions({ locale }),
staleTime: Infinity,
});
const { data: breedList = [], isLoading: breedsLoading } = useQuery({
...trpc.pets.listBreeds.queryOptions(
{ speciesId: selectedSpeciesId || "__none__" },
{ speciesId: selectedSpeciesId || "__none__", locale },
),
enabled: !!selectedSpeciesId,
staleTime: Infinity,
@@ -100,11 +103,11 @@ export function PetForm({
...trpc.pets.create.mutationOptions(),
onSuccess: (pet) => {
void queryClient.invalidateQueries();
toast.success("Pet profile created");
toast.success(t("petCreated"));
onSuccess?.(pet.id);
},
onError: () => {
toast.error("Something went wrong. Please try again.");
toast.error(t("error"));
},
});
@@ -112,11 +115,11 @@ export function PetForm({
...trpc.pets.update.mutationOptions(),
onSuccess: () => {
void queryClient.invalidateQueries();
toast.success("Changes saved");
toast.success(t("changesSaved"));
onSuccess?.(petId!);
},
onError: () => {
toast.error("Something went wrong. Please try again.");
toast.error(t("error"));
},
});
@@ -191,11 +194,11 @@ export function PetForm({
{/* Pet name */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="pet-name">Pet name</Label>
<Label htmlFor="pet-name">{t("name")}</Label>
<Input
id="pet-name"
maxLength={30}
placeholder="Enter pet name"
placeholder={t("namePlaceholder")}
aria-invalid={!!errors.name}
{...register("name")}
onChange={(e) => {
@@ -211,7 +214,7 @@ export function PetForm({
{/* Species selector — defer render until list loaded so SelectValue resolves names */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="species">Species</Label>
<Label htmlFor="species">{t("species")}</Label>
{speciesLoading ? (
<Skeleton className="h-9 w-full" />
) : (
@@ -233,7 +236,7 @@ export function PetForm({
aria-invalid={!!errors.speciesId}
>
<span className={field.value ? "text-foreground" : "text-muted-foreground"}>
{speciesList.find((s) => s.id === field.value)?.name ?? "Select species"}
{speciesList.find((s) => s.id === field.value)?.name ?? t("selectSpecies")}
</span>
</SelectTrigger>
<SelectContent>
@@ -256,7 +259,7 @@ export function PetForm({
{/* Breed selector — defer render until list loaded so SelectValue resolves names */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="breed">Breed</Label>
<Label htmlFor="breed">{t("breed")}</Label>
{selectedSpeciesId && breedsLoading ? (
<Skeleton className="h-9 w-full" />
) : (
@@ -276,16 +279,16 @@ export function PetForm({
>
<span className={field.value ? "text-foreground" : "text-muted-foreground"}>
{field.value
? (breedList.find((b) => b.id === field.value)?.name ?? "Select breed (optional)")
? (breedList.find((b) => b.id === field.value)?.name ?? t("selectBreed"))
: selectedSpeciesId
? "Select breed (optional)"
: "Select a species first"}
? t("selectBreed")
: t("selectSpeciesFirst")}
</span>
</SelectTrigger>
<SelectContent>
{breedList.length === 0 ? (
<SelectItem value="__empty__" disabled>
No breeds found for this species.
{t("noBreedsFound")}
</SelectItem>
) : (
breedList.map((breed) => (
@@ -306,7 +309,7 @@ export function PetForm({
{/* Bio */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="bio">Bio</Label>
<Label htmlFor="bio">{t("bio")}</Label>
<Textarea
id="bio"
maxLength={150}
@@ -324,7 +327,7 @@ export function PetForm({
{/* Adoption story */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="adoption-story">Adoption story</Label>
<Label htmlFor="adoption-story">{t("adoptionStory")}</Label>
<Textarea
id="adoption-story"
maxLength={500}
@@ -346,24 +349,24 @@ export function PetForm({
{mode === "edit" && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="birthday">Geburtstag</Label>
<Label htmlFor="birthday">{t("birthday")}</Label>
<Input
id="birthday"
type="date"
value={birthdayValue}
{...register("birthday")}
/>
<p className="text-xs text-muted-foreground">Für Geburtstags-Erinnerungen</p>
<p className="text-xs text-muted-foreground">{t("birthdayHint")}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="adopted-at">Adoptionstag</Label>
<Label htmlFor="adopted-at">{t("adoptedAt")}</Label>
<Input
id="adopted-at"
type="date"
value={adoptedAtValue}
{...register("adoptedAt")}
/>
<p className="text-xs text-muted-foreground">Für Jahrestags-Erinnerungen</p>
<p className="text-xs text-muted-foreground">{t("adoptedAtHint")}</p>
</div>
</div>
)}
@@ -371,7 +374,7 @@ export function PetForm({
{/* Submit CTA */}
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading && <Loader2 className="animate-spin" />}
{mode === "create" ? "Add Pet" : "Save changes"}
{mode === "create" ? t("addPet") : t("saveChanges")}
</Button>
</form>
);
+11 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Camera, BookOpen, Trophy, Video } from "lucide-react";
import {
Sheet,
@@ -44,6 +45,7 @@ interface PostTypeSheetProps {
type PostType = "photo" | "story" | "milestone" | "video" | null;
export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
const t = useTranslations("PostType");
const [selectedType, setSelectedType] = useState<PostType>(null);
const [photoDialogOpen, setPhotoDialogOpen] = useState(false);
@@ -90,7 +92,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="pb-8">
<SheetHeader className="mb-4">
<SheetTitle className="text-xl font-semibold">Create</SheetTitle>
<SheetTitle className="text-xl font-semibold">{t("title")}</SheetTitle>
</SheetHeader>
<div className="space-y-3">
@@ -101,7 +103,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
onClick={() => handleSelectType("photo")}
>
<Camera className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Photo Post</span>
<span>{t("photo")}</span>
</Button>
{/* Story option — seam for 02-05 */}
@@ -111,7 +113,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
onClick={() => handleSelectType("story")}
>
<BookOpen className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Story</span>
<span>{t("story")}</span>
</Button>
{/* Video Post option */}
@@ -121,7 +123,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
onClick={() => handleSelectType("video")}
>
<Video className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Video Post</span>
<span>{t("video")}</span>
</Button>
{/* Milestone option — seam for 02-06 */}
@@ -131,7 +133,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
onClick={() => handleSelectType("milestone")}
>
<Trophy className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<span>Milestone</span>
<span>{t("milestone")}</span>
</Button>
</div>
</SheetContent>
@@ -141,7 +143,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<Dialog open={photoDialogOpen} onOpenChange={setPhotoDialogOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Post</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("newPost")}</DialogTitle>
</DialogHeader>
<PhotoPostForm onClose={handlePhotoClose} />
</DialogContent>
@@ -151,7 +153,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<Dialog open={storyDialogOpen} onOpenChange={setStoryDialogOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Story</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("newStory")}</DialogTitle>
</DialogHeader>
<StoryForm onClose={handleStoryClose} />
</DialogContent>
@@ -161,7 +163,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<Dialog open={milestoneDialogOpen} onOpenChange={setMilestoneDialogOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Milestone</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("newMilestone")}</DialogTitle>
</DialogHeader>
<MilestoneForm onClose={handleMilestoneClose} />
</DialogContent>
@@ -171,7 +173,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
<Dialog open={videoDialogOpen} onOpenChange={setVideoDialogOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Video</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("newVideo")}</DialogTitle>
</DialogHeader>
<VideoUploadForm onClose={() => { setVideoDialogOpen(false); setSelectedType(null); }} />
</DialogContent>
+19
View File
@@ -0,0 +1,19 @@
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
const LOCALES = ["en", "de"] as const;
export type Locale = (typeof LOCALES)[number];
export const defaultLocale: Locale = "en";
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const raw = cookieStore.get("NEXT_LOCALE")?.value ?? "";
const locale: Locale = (LOCALES as readonly string[]).includes(raw)
? (raw as Locale)
: defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
+42 -12
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import type { PrismaClient } from "@prisma/client";
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
/**
* Ownership assertion helper — verifies that a pet belongs to the requesting
@@ -51,6 +52,19 @@ export const petsRouter = router({
create: { id: ctx.userId },
});
// Ensure every owner has starter invite codes (idempotent)
const existingCodes = await ctx.prisma.inviteCode.count({
where: { issuedById: ctx.userId },
});
if (existingCodes === 0) {
await ctx.prisma.inviteCode.createMany({
data: Array.from({ length: INVITE_CODES_PER_USER }, () => ({
code: generateInviteCode(),
issuedById: ctx.userId,
})),
});
}
return ctx.prisma.pet.create({
data: {
ownerId: ctx.userId,
@@ -180,20 +194,36 @@ export const petsRouter = router({
* List all species.
* Protected procedure — auth required to prevent unauthenticated taxonomy scraping (T-3-04).
*/
listSpecies: protectedProcedure.query(async ({ ctx }) => {
return ctx.prisma.species.findMany({ orderBy: { name: "asc" } });
}),
/**
* List breeds for a given species.
* Protected procedure (T-3-04).
*/
listBreeds: protectedProcedure
.input(z.object({ speciesId: z.string().min(1) }))
listSpecies: protectedProcedure
.input(z.object({ locale: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.prisma.breed.findMany({
where: { speciesId: input.speciesId },
const locale = input?.locale;
const species = await ctx.prisma.species.findMany({
include: locale ? { translations: { where: { locale }, take: 1 } } : undefined,
orderBy: { name: "asc" },
});
return species.map((s) => ({
id: s.id,
name: (locale && "translations" in s && (s as { translations: { name: string }[] }).translations[0]?.name)
? (s as { translations: { name: string }[] }).translations[0].name
: s.name,
}));
}),
listBreeds: protectedProcedure
.input(z.object({ speciesId: z.string().min(1), locale: z.string().optional() }))
.query(async ({ ctx, input }) => {
const { speciesId, locale } = input;
const breeds = await ctx.prisma.breed.findMany({
where: { speciesId },
include: locale ? { translations: { where: { locale }, take: 1 } } : undefined,
orderBy: { name: "asc" },
});
return breeds.map((b) => ({
id: b.id,
name: (locale && "translations" in b && (b as { translations: { name: string }[] }).translations[0]?.name)
? (b as { translations: { name: string }[] }).translations[0].name
: b.name,
}));
}),
});