From 4b17537a9a271ebaa1b1d405415f482e9e2a1f0e Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Mon, 22 Jun 2026 14:01:16 +0200 Subject: [PATCH] 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 --- favicon.ico | Bin 0 -> 15406 bytes messages/de.json | 106 +++ messages/en.json | 106 +++ next.config.ts | 5 +- package-lock.json | 698 +++++++++++++++++- package.json | 1 + prisma/migrations/add_i18n_translations.sql | 30 + prisma/schema.prisma | 40 +- prisma/seed.ts | 87 +++ src/app/(app)/explore/page.tsx | 42 +- src/app/api/auth/consume-invite/route.ts | 25 +- src/app/api/auth/set-invite/route.ts | 35 + src/app/join/page.tsx | 31 +- src/app/layout.tsx | 23 +- src/app/onboarding/pet/page.tsx | 56 +- src/components/feed/EmptyFeed.tsx | 28 +- src/components/feed/WelcomeCard.tsx | 56 +- src/components/i18n/LanguageSwitcher.tsx | 46 ++ src/components/layout/MobileNav.tsx | 10 +- src/components/layout/Sidebar.tsx | 33 +- src/components/pet/AvatarUpload.tsx | 26 +- src/components/pet/PetForm.tsx | 47 +- .../post-creation/PostTypeSheet.tsx | 20 +- src/i18n/request.ts | 19 + src/trpc/routers/pets.ts | 54 +- 25 files changed, 1416 insertions(+), 208 deletions(-) create mode 100644 favicon.ico create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 prisma/migrations/add_i18n_translations.sql create mode 100644 src/app/api/auth/set-invite/route.ts create mode 100644 src/components/i18n/LanguageSwitcher.tsx create mode 100644 src/i18n/request.ts diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7fe8ef81b2c33d272cc1aec23a65d74cc8b4ce61 GIT binary patch literal 15406 zcmeHO2XI}-b%lzPOp2_`IK_72$X1*v$#~*e66IKqM^Pksl1L*eBZeeMiK3}OiRxV> z2!b8#6(j+6QiTnyBETYcu%Z(kMDOL}<9~eo_qM$EPtHCBG6;hZKx-(EjfdHJ`}b|R zd-v|Wch6l!Mcov2OVsVRNAZ0})Wln(qJ~99Mcr{n*YCUjG%9KX%YOL7|Ns7XQBmjq zEGp_Q)?pL2=ql&yD|rCiHjsha0)Yw5K3`>Hb8|&~V`F7?U0r2!YimVQb91=4rM0|0 z7%Zr*t1qc*Xs8SZgRl4Ov#G;*EZ8Y8)wgz_wLOg5)=v0ClKBw)0SoobK{Wbo6qWl> z+Z04Yt2h}Bhrij~$AK)lYyLrn+4~jV+b%KZfWn&}ORPPnux7W!rcWgfT@Z*_EwFyS z#I_?6dF5(-cl*<&>chDQBo1UNXWc1Tcir zU0SE|mhV;AljmU9IU5T&r~Rj8LGOCk`hDnv$`~=lMM|E99mN)wlP@V}W%jk|_bjjR zsb><7I`-0?3LkQ=s}Ct8?KM2pc72PQ(6eq|`J|Jo;773$Nq5!ys<`f2F-?c{(E z;?7Vg^ygpZ?1U08Ye%1Cd z6Dn(};q&`JK3T1;t)JVr-gHPQ|DmQ$TlIeJl22$3I;i$J-du|69dt?;=gTxKzKa?h zGzDD<=F94|sm^*pUYSB>u|!L|gTg9>EavN4G|KBWa!M82f=&nRSf9F{<3}f*lvr_8 zz$OWw6(~HNq_Lj1^1l~pq?HN`i*vC%Q{nL?3NgF1H^%h!(i^+cr!X;I;o~C$*`*S{ zm?`j4sz64e#EbJJ*6$N|ZIO+dX06GK@AfhO=)~g^k1bXhzD&?gOFS5_vGOw;W30PpD&P4`!ivdz|=J%ju~cN(XI$oG)Rn#6IjT4B{igLoGu&)dG(#XOCzcRjM{UobOWAZMA zxd${F{MssQcJvhf?ywy!r*G2M>A)9qpxp>|(3iQy^Vir0DPvk+7);*-sY{u9edh^*|BZ3*9GK>P2n9{U-VU$6Cpb2WY(=OCZ@K5m1=tj*L%T!W1V z&6+FB*=+QX#?w<3o|`F+&UY-W@9(egfqbJ4-}Kx@1>^gh^x-*)X4;qMsOv{gbuoIX zgGmb{ns^>Mz;nfzDUNq0n6b{&vmXv*jQ;9y{DY4#)A-TU2=0z`k;^$&w`t^737p81 zI7XdTzmB z#n_kVsS?pK0_psAAWh)S#R8vQk`P+FJ&-Z_t3zFOv()kf%5*BvpLJtsL+;@^42z3k z^>KwyXm3YzzR{Bx|J~+m@D=KNi|a zDQQxXkWj^xjen-^{ZC<`3o05^L~7@7TU*^ zzeW81Cg0#BF-h*WYjBqq48(IYy|0n4iJS49?a@ z%#1i7!o=2^TUuc7Hp{lXvMUu8-a8OtT*=qw;cZnlHK?qv@$$qYS?)tkT|ELF9SC)H zy2L3WrccuAVRiohbsBR4M}mol?FdVRziFJ@+< ziF3}($w75(9ftuQ@mbd`77m`Z z(80dW7ueW!!p5Ex7LIY8=5~!##v%8fvWXM2eb^Di)J~Bn-|J0t&)>u(zQc1?Y4mCf z(L8e+yqJ(AYW*%AU97P6tOBu1Y$tB|z#JExCu4TX!;cpbgFTY3@$Yk8Ji%CAbBBYY zJYT;sPa~hWnt2Saov=&dx!DTut{}#`GK9)Tjd9a$yc93-){-z5Yz~udf$`Hd-sD;T z#kmsiEbl}!@pG=l{QUg<-|I-{r{qa<2* zn7H+0Jj+)STNuBV*w+eyQN(@cr|`VL%Eo`hJ2+D!J^ClikscqjON6%`OFt2aTN_4E zwL~p(!?qCVCnmjxcuhg2gs($0b}jJ?@w%E;>0Pa6NSC(S+c9{oy} zj$UcuA&wij$HvbWD7?clKVqN%Hk+7eksyAruiil?X28kqu!@5Hdf$ywttlvz|Lb9=~o`! zNS^cD9l_hnEW{>t8Vt6aH2zIbntSHE=^sVe8N1qspR)P#7J(7V9b{B1{MSMUaeD>d zg0T)_dYJ-`GUia-=3@9#+lvd7x43wAorS54L+?qmJ?uY?a@ItdZs$HQ^iNAN{1>Pv zf7^-OJvB{W^Ff<&3)%;HMe1r{C&HbI&~G|F0?k z1uty0DBp|?Y*Tocc-Wl%Hp0r~T?N54aPZ1j2|sC>nrvZ`$%kF+&E)+BVw52fLBcT` zW7jaAaNfq##PFuC7g)p?$DWg6#@7X2VvJ)6*EC_5jXEQPGc3GE8C=X5$EHthq@EXe zc9upwvA_2d7+>H$h_X6qbkSn+_?oti-)^JM-%-@47~2p?=Kf2&q7ewYih8uAw%u{J zo@0E4SfkN%>BKiL5$kri$4%@(aIKBPst-6gPRy&l)5Yl=iB!s-k?CS$VpZI)1{+E} zPd&vLM96lLbA|UWINl-BZQ>w4?)}rbg7p+GvY%G=N4nAj^rd?8yRSU&X52*|Wd}EV zp?2Lu&iS;m)wAvG?RQWQZ=hZ*X4w(0<0R_o4F2vgy0SZw{Hvb6%1s$9hVdQhjE!VU zW4^#7<}=v;PUf@tF60KW?2T-wp?IS#gD(5I(< zpLM~9dd6QCo>w?iF7fh0jnRyk?LB4elTQ7r=;zvRKSQ}0H;c5#bj8n149&!n>S>26 z7%$#Z7{sCsKk{j3XQxrG76`mZc^bz!Ln`M(`uBWQ^wS@lSXiYa6KLy3Fuv0j19Y+W zfQ=(nAxt~hhL|)TE;QKKoXglV6WvP@83w8%~;fV zU!&~Y%kxQ!B^}$QPG9tih0)U-jEapQiFYb4H*0L?US4v6_wp|Bo(FCE<`f&_rcpm_ zvthA~P>xN1HzZG<4f?2!jE5Tg zH8zGi=mQ7O#A(lGrmu3D^#9dhXt|!c1N6esqJ(l`*40Y%j&f~>+i88L2Wyz7(L(^c|kp5Yz z)R{k>-NpN^_-_QUyl-KAAfCB#oacfWjD=5}FY$al&s+4LPv>;eZv3Go+$&=ylYYi0 zM^7=nK5ZQRRPL#TgQ4Ym>T0Q{nfpQ>{oJc2c76}{?WGFk-Q_yMJ)1&WjVv{^IgDRB zJY#8`%CUJTndb=laJglI{1+bmW)9=yNPn(pi{r0{ropr!{_!*74&m|ZBz`)}#X~%w zC-J=9&a+w!&*t}WFWgIcnaO<}=6T)bo#?fDZH$VMj7Re>%61DOo_CUoH%#Oib%HtH zB=LOa*Ea9jJv8bAl6n5RkM}4{4E;XF&>!Z!D#-h~ zR)@Zu#r>;1`o~VC&fytZvQ0rn*BOTEBiUyXFmRmhdT8%%!#R9C+;t}_p7tk!XS2Xd z+`o5|c9VvY|K=V-9`6U5yB{Mz=!%1nA^**tfcQ-wh81MnMDC|~O#cJ4-`?H-jeJ{H z$NkhPO(Ry?-d-3s4U`{v=jSr|cC}2;&Y-@eoEuq>rXGEFS(s-Ux0Ukp(2bQ z4d3VCxcdE_cGvv}%FeyitFQ5np+D@n#Lk@f&E)?Fq~9d!%%$5x=8jq|>Hk4recs42 z%F?Zr-F09owrP`XuJ@VSZu*d(G;={bts0&FdJ>34_P*oc~SIde|Rw?6cILRn64$+AR^Hga3K+oAU$$fjdbj z??j44N{LCtu|LPj%sg23BEK(mUHvxq?ce_9Q2t9wC`&gPdHQ9^)j9Lc)VaEJ=+_Vj ShBz?9fguhIao~541OEq!Z_=Cq literal 0 HcmV?d00001 diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 0000000..cc079b6 --- /dev/null +++ b/messages/de.json @@ -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" + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..258d65b --- /dev/null +++ b/messages/en.json @@ -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" + } +} diff --git a/next.config.ts b/next.config.ts index 2ef615c..a8e3cd7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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); diff --git a/package-lock.json b/package-lock.json index ebdb329..e124b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 625a765..2e7ae01 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/add_i18n_translations.sql b/prisma/migrations/add_i18n_translations.sql new file mode 100644 index 0000000..6058bf3 --- /dev/null +++ b/prisma/migrations/add_i18n_translations.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b605ac..b74ba39 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/prisma/seed.ts b/prisma/seed.ts index f862b04..c4c0c3b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -75,6 +75,72 @@ const BIRD_BREEDS = [ "Amazon Parrot", ]; +const SPECIES_DE: Record = { + "Dog": "Hund", + "Cat": "Katze", + "Bird": "Vogel", +}; + +const DOG_BREEDS_DE: Record = { + "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 = { + "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 = { + "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> = { + "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}`); } } diff --git a/src/app/(app)/explore/page.tsx b/src/app/(app)/explore/page.tsx index 145c1eb..e87cf9c 100644 --- a/src/app/(app)/explore/page.tsx +++ b/src/app/(app)/explore/page.tsx @@ -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 (
-

Explore

+

{t("title")}

- Trending + {t("trending")} - All + {t("all")} {(speciesList ?? []).map((s) => ( {s.name} @@ -113,10 +116,10 @@ export default function ExplorePage() { onValueChange={(v) => setActiveBreedId(v === "all" ? null : v)} > - + - All breeds + {t("allBreeds")} {breeds.map((b) => ( {b.name} @@ -144,14 +147,13 @@ export default function ExplorePage() {
) : pets.length === 0 ? (

- 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 ?? "") + : "", + })}

) : ( <> @@ -175,7 +177,7 @@ export default function ExplorePage() { {isFetchingNextPage ? ( ) : null} - Load more + {t("loadMore")} )} @@ -239,6 +241,8 @@ function TrendingContent({ isLoading: boolean; onSelectPost: (post: PostCardPost) => void; }) { + const t = useTranslations("Explore"); + if (isLoading) { return (
@@ -254,7 +258,7 @@ function TrendingContent({ if (!hasContent) { return (

- No trending content yet — check back soon + {t("noTrending")}

); } @@ -265,7 +269,7 @@ function TrendingContent({ {hashtags.length > 0 && (

- Trending hashtags + {t("trendingHashtags")}

{hashtags.map((ht) => ( @@ -289,7 +293,7 @@ function TrendingContent({ {posts.length > 0 && (

- Hot posts · last 48 h + {t("hotPosts")}

{posts.map((post) => { diff --git a/src/app/api/auth/consume-invite/route.ts b/src/app/api/auth/consume-invite/route.ts index 50bd480..387d7b3 100644 --- a/src/app/api/auth/consume-invite/route.ts +++ b/src/app/api/auth/consume-invite/route.ts @@ -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) { diff --git a/src/app/api/auth/set-invite/route.ts b/src/app/api/auth/set-invite/route.ts new file mode 100644 index 0000000..fb11bde --- /dev/null +++ b/src/app/api/auth/set-invite/route.ts @@ -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; +} diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx index 8a5c1b3..5b93963 100644 --- a/src/app/join/page.tsx +++ b/src/app/join/page.tsx @@ -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 (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5e03586..8a5c72d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + - - - {children} - - - + + + + {children} + + + + diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index ebd1dc1..f621090 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -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(null); + + if (createdPetId) { + return ( +
+
+
+

{t("addPhoto")}

+

{t("addPhotoDesc")}

+
+ + {}} /> + +
+ + +
+
+
+ ); + } return (
-

Add your first pet

-

- Your pet will be their own star on PawFeed. -

- +

{t("addFirstPet")}

+

{t("addFirstPetDesc")}

router.push(`/pets/${petId}`)} + onSuccess={(petId) => setCreatedPetId(petId)} />
diff --git a/src/components/feed/EmptyFeed.tsx b/src/components/feed/EmptyFeed.tsx index 9c30bec..ed64335 100644 --- a/src/components/feed/EmptyFeed.tsx +++ b/src/components/feed/EmptyFeed.tsx @@ -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 (
- {/* Heading — text-xl font-semibold per UI-SPEC typography */}

- Your feed is empty + {t("emptyTitle")}

- - {/* Body — text-sm per UI-SPEC */}

- Follow some pets to see their posts here. + {t("emptyBody")}

- - {/* Find Pets CTA — primary button style, links to /explore (Phase 3 placeholder) */} - Find Pets + {t("findPets")}
); diff --git a/src/components/feed/WelcomeCard.tsx b/src/components/feed/WelcomeCard.tsx index 290d796..627914a 100644 --- a/src/components/feed/WelcomeCard.tsx +++ b/src/components/feed/WelcomeCard.tsx @@ -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 }) {
🐾 -

Willkommen bei PawFeed!

+

{t("title")}

-

- Hier ist, was dein Haustier alles tun kann: -

+

{t("subtitle")}

@@ -71,14 +51,14 @@ export function WelcomeCard({ petId }: { petId: string }) { {/* Feature grid */}
- {FEATURES.map(({ icon: Icon, title, desc }) => ( -
+ {FEATURE_DEFS.map(({ icon: Icon, titleKey, descKey }) => ( +
-

{title}

-

{desc}

+

{t(titleKey)}

+

{t(descKey)}

))} @@ -86,11 +66,9 @@ export function WelcomeCard({ petId }: { petId: string }) { {/* CTA */}
-

- Drücke + in der Navigation um deinen ersten Post zu erstellen. -

+

{t("ctaHint")}

diff --git a/src/components/i18n/LanguageSwitcher.tsx b/src/components/i18n/LanguageSwitcher.tsx new file mode 100644 index 0000000..b27649a --- /dev/null +++ b/src/components/i18n/LanguageSwitcher.tsx @@ -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 ( +
+ + {t("switch")}: + {LOCALES.map((l) => ( + + ))} +
+ ); +} diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index 84f31da..8b6923f 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -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" > + - Add Pet + {t("addPet")} @@ -146,7 +148,7 @@ export function MobileNav({ pets }: MobileNavProps) { }`} >
- {uploadProgress > 0 ? `${uploadProgress}%` : "Uploading…"} + {uploadProgress > 0 ? `${uploadProgress}%` : t("uploading")}
); @@ -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")} > @@ -299,7 +301,7 @@ export function AvatarUpload({ {/* Hover overlay */}
{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")} >
{isError && errorMessage && (

diff --git a/src/components/pet/PetForm.tsx b/src/components/pet/PetForm.tsx index 0af5b90..e9d36b4 100644 --- a/src/components/pet/PetForm.tsx +++ b/src/components/pet/PetForm.tsx @@ -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( @@ -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 */}

- + { @@ -211,7 +214,7 @@ export function PetForm({ {/* Species selector — defer render until list loaded so SelectValue resolves names */}
- + {speciesLoading ? ( ) : ( @@ -233,7 +236,7 @@ export function PetForm({ aria-invalid={!!errors.speciesId} > - {speciesList.find((s) => s.id === field.value)?.name ?? "Select species"} + {speciesList.find((s) => s.id === field.value)?.name ?? t("selectSpecies")} @@ -256,7 +259,7 @@ export function PetForm({ {/* Breed selector — defer render until list loaded so SelectValue resolves names */}
- + {selectedSpeciesId && breedsLoading ? ( ) : ( @@ -276,16 +279,16 @@ export function PetForm({ > {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")} {breedList.length === 0 ? ( - No breeds found for this species. + {t("noBreedsFound")} ) : ( breedList.map((breed) => ( @@ -306,7 +309,7 @@ export function PetForm({ {/* Bio */}
- +