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:
1 parent
8645421c1e
commit
4b17537a9a
25 files changed
+1416
-208
No files matched your search
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
Generated
+697
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user