From 3b8ef0b25c013904700e52baa69f6752c55f077f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:15:32 +1000 Subject: [PATCH 1/7] add i18n translation hygiene --- .github/workflows/validate-app.yml | 29 ++ messages/de.json | 363 +++++++++++++++++- messages/en.json | 361 ++++++++++++++++- messages/es.json | 363 +++++++++++++++++- package.json | 2 + scripts/check-i18n-messages.mjs | 266 +++++++++++++ .../(interact)/(centered)/profile/page.js | 8 +- .../newsletter/(issues)/[slug]/page.tsx | 18 +- src/app/(core)/(static)/newsletter/page.tsx | 18 +- src/app/(forms)/auth/complete/page.tsx | 11 +- src/app/(forms)/forgot-password/page.tsx | 17 +- .../(forms)/profile/listings/[slug]/page.js | 6 +- src/app/(forms)/profile/listings/new/page.js | 72 ++-- .../(forms)/profile/reset-password/page.tsx | 26 +- src/app/(forms)/sign-in/page.tsx | 8 +- src/app/actions.ts | 92 ++--- src/app/not-found.js | 9 +- .../AvatarUploadManager.jsx | 8 +- .../AvatarUploadView/AvatarUploadView.tsx | 26 +- .../ButtonToDialog/ButtonToDialog.tsx | 10 +- src/components/ChatComposer/ChatComposer.tsx | 12 +- src/components/ChatHeader/ChatHeader.jsx | 31 +- src/components/ChatWindow/ChatWindow.tsx | 19 +- src/components/IntroHeader/IntroHeader.jsx | 6 +- src/components/Label/Label.jsx | 5 +- .../LegalAgreement/LegalAgreement.jsx | 32 +- src/components/LegalFooter/LegalFooter.jsx | 15 +- src/components/ListingCta/ListingCta.jsx | 36 +- .../ListingHeader/ListingHeader.jsx | 18 +- .../ListingPhotosManager.tsx | 36 +- src/components/ListingRead/ListingRead.jsx | 67 ++-- src/components/ListingWrite/ListingWrite.tsx | 172 +++++---- .../LocationSelect/LocationSelect.jsx | 25 +- .../MapPageClient/MapPageClient.jsx | 29 +- src/components/MapSearch/MapSearch.jsx | 9 +- src/components/MapSidebar/MapSidebar.jsx | 26 +- .../NewsletterAside/NewsletterAside.jsx | 35 +- .../NewsletterCallout/NewsletterCallout.jsx | 47 +-- .../NewsletterIssuesList.tsx | 6 +- src/components/PostageStamp/PostageStamp.jsx | 5 +- .../ProfileAccountSettings.tsx | 70 ++-- .../ProfileActions/ProfileActions.tsx | 68 ++-- .../ProfileHeader/ProfileHeader.jsx | 5 +- .../ProfileListings/ProfileListings.jsx | 23 +- src/components/SignInForm/SignInForm.jsx | 16 +- src/components/SignUpForm/SignUpForm.tsx | 82 ++-- src/components/ThreadsList/ThreadsList.jsx | 6 +- 47 files changed, 2045 insertions(+), 569 deletions(-) create mode 100644 .github/workflows/validate-app.yml create mode 100644 scripts/check-i18n-messages.mjs diff --git a/.github/workflows/validate-app.yml b/.github/workflows/validate-app.yml new file mode 100644 index 00000000..7e682a6b --- /dev/null +++ b/.github/workflows/validate-app.yml @@ -0,0 +1,29 @@ +name: Validate App + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run app checks + run: npm run check diff --git a/messages/de.json b/messages/de.json index b8202eb2..20c99de2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6,12 +6,110 @@ "about": "Über" }, "Actions": { + "add": "Hinzufügen", + "addListing": "Eintrag hinzufügen", + "addMorePhotos": "Weitere Fotos hinzufügen", + "addPhotos": "Fotos hinzufügen", + "backToPeels": "Zurück zu Peels", + "cancel": "Abbrechen", + "close": "Schließen", + "continue": "Weiter", + "delete": "Löschen", + "deleteListing": "Eintrag löschen", + "done": "Fertig", + "edit": "Bearbeiten", + "emailLink": "Link per E-Mail senden", + "exportData": "Daten exportieren", + "home": "Startseite", + "joinPeels": "Peels beitreten", + "noCancel": "Nein, abbrechen", + "openInAppleMaps": "In Apple Karten öffnen", + "openInGoogleMaps": "In Google Maps öffnen", + "replace": "Ersetzen", + "resetPassword": "Passwort zurücksetzen", + "saveChanges": "Änderungen speichern", + "seeNearbyListings": "Einträge in der Nähe ansehen", + "sendLink": "Link senden", + "signIn": "Anmelden", + "signInToContact": "Zum Kontaktieren anmelden", + "signOut": "Abmelden", "signUp": "Registrieren", - "signIn": "Anmelden" + "update": "Aktualisieren", + "viewFullListing": "Vollständigen Eintrag ansehen", + "viewListing": "Eintrag ansehen" + }, + "Common": { + "account": "Konto", + "actions": "Aktionen", + "admin": "Admin", + "email": "E-Mail", + "hidden": "Ausgeblendet", + "links": "Links", + "listings": "Einträge", + "loading": "Lädt...", + "newsletter": "Newsletter", + "notSubscribed": "Nicht abonniert", + "optional": "optional", + "password": "Passwort", + "photos": "Fotos", + "source": "Quelle", + "stub": "Stub", + "subscribed": "Abonniert" + }, + "Status": { + "adding": "Wird hinzugefügt...", + "copying": "Wird kopiert...", + "deleting": "Wird gelöscht...", + "emailing": "E-Mail wird gesendet...", + "resetting": "Wird zurückgesetzt...", + "saving": "Wird gespeichert...", + "sending": "Wird gesendet...", + "signingIn": "Anmeldung läuft...", + "signingOut": "Abmeldung läuft...", + "signingUp": "Registrierung läuft...", + "updating": "Wird aktualisiert...", + "uploading": "Wird hochgeladen...", + "verifying": "Wird verifiziert...", + "working": "Wird verarbeitet..." + }, + "Errors": { + "alreadyYourEmail": "Das ist bereits deine E-Mail-Adresse.", + "emptyListingName": "Der Name für {type} darf nicht leer sein.", + "emptyName": "Der Name darf nicht leer sein.", + "accountDeleted": "Dein Konto wurde gelöscht. Schade, dass du gehst.", + "accountExists": "Ein Konto mit dieser E-Mail existiert bereits. Bitte melde dich stattdessen an.", + "deleteAccountFailed": "Fehler beim Löschen des Kontos", + "duplicateListing": "Ein identischer Eintrag existiert bereits.", + "emailRequired": "E-Mail ist erforderlich", + "failedDeleteListing": "Der Eintrag konnte nicht gelöscht werden.", + "failedDeletePhoto": "Das Foto konnte nicht gelöscht werden. Bitte versuche es erneut.", + "generic": "Hmm, da stimmt etwas nicht. Versuchst du es noch einmal?", + "genericLater": "Etwas ist schiefgelaufen. Bitte versuche es später erneut.", + "missingLocation": "Bitte wähle einen Standort aus.", + "missingSignUpFields": "Vorname, E-Mail und Passwort sind erforderlich.", + "passwordMismatch": "Diese Passwörter stimmen nicht überein.", + "photoUploadFailed": "Beim Hochladen deiner Fotos ist ein Fehler aufgetreten. Bitte versuche es erneut.", + "avatarUploadFailed": "Beim Hochladen deines Fotos ist ein Fehler aufgetreten. Bitte versuche es erneut.", + "requiredPasswordFields": "Beide Felder sind erforderlich.", + "resetPasswordDenied": "Hmm, da stimmt etwas nicht. Vielleicht hast du keine Berechtigung, dieses Passwort zurückzusetzen, oder du hast versucht, ein kürzlich verwendetes Passwort erneut zu nutzen.", + "resetPasswordSuccess": "Dein Passwort wurde aktualisiert. Zurück zum Kompostieren!", + "savePhotosFailed": "Der Eintrag wurde erstellt, aber die Fotos konnten nicht gespeichert werden.", + "signUpFailed": "Registrierung fehlgeschlagen", + "tooManyListings": "Du hast die maximale Anzahl erlaubter Einträge erreicht. Lösche einen deiner aktuellen drei Einträge, um einen neuen zu erstellen.", + "tooManyMessages": "Du hast zu viele Nachrichten gesendet. Bitte versuche es später erneut.", + "updateEmailFailed": "Hmm, da stimmt etwas nicht. Prüfe deine E-Mail oder versuche es erneut.", + "updateFirstNameFailed": "Entschuldigung, wir konnten deinen Vornamen nicht aktualisieren.", + "updateNewsletterFailed": "Entschuldigung, wir konnten deine Newsletter-Einstellung nicht aktualisieren.", + "unexpected": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später erneut.", + "validationSummary": "Bitte behebe {count, plural, one {den obigen Fehler} other {die obigen Fehler}} und versuche es erneut.", + "verificationChallenge": "Bitte schließe die Verifizierung ab.", + "verificationFailed": "Die Verifizierung ist fehlgeschlagen. Bitte versuche es erneut.", + "forgotPasswordSuccess": "Prüfe deinen Posteingang (und Spam) für einen Link zum Zurücksetzen des Passworts, sofern diese E-Mail-Adresse mit einem Peels-Konto verknüpft ist." }, "Index": { "title": "Finde ein Zuhause für deine Küchenabfälle, wo auch immer du bist", "subtitle": "Peels verbindet die Menschen mit Lebensmittelkratzern mit denen, die komponieren. Es ist ein kostenloses und nicht-kommerzielles Gemeinschaftsprojekt.", + "hostAvatarAlt": "Der Avatar eines Peels-Gastgebers", "buttons": { "browseMap": "Karte durchsuchen", "signUp": "Bei Peels anmelden", @@ -64,11 +162,270 @@ } }, "Profile": { - "addListing": "Angebot hinzufügen" + "addListing": "Eintrag hinzufügen", + "addAnotherListing": "Weiteren Eintrag hinzufügen", + "listingCardAlt": "Dein Avatar für diesen {type}-Eintrag", + "listingCardType": "{type}-Eintrag", + "listingPrompt": "Setze dich, deinen Gemeinschaftsort oder dein Unternehmen auf die Karte", + "sections": { + "listings": "Einträge", + "account": "Konto", + "actions": "Aktionen" + }, + "account": { + "firstName": "Vorname", + "firstNameHint": "Verwende deinen Vornamen oder einen Spitznamen.", + "emailSuccess": "Prüfe deine E-Mails für den Bestätigungslink.", + "emailHint": "Wir senden einen Bestätigungslink an diese E-Mail.", + "newsletterSubscribedHint": "Wir senden dir gelegentliche E-Mail-Updates über Peels.", + "newsletterNotSubscribedHint": "Wir senden dir nur notwendige E-Mails zu deinem Konto oder deinen Einträgen." + }, + "actions": { + "signOutTitle": "Abmelden", + "signOutDescription": "Bis bald!", + "exportTitle": "Daten exportieren", + "exportDescription": "Erhalte eine Kopie deiner Peels-Daten", + "exportDialogTitle": "Demnächst", + "exportDialog": "Wir arbeiten noch an dieser Funktion. In der Zwischenzeit kannst du uns kontaktieren und uns bitten, deine Daten manuell zu exportieren.", + "deleteTitle": "Konto löschen", + "deleteDescription": "Lösche dein Konto{count, plural, =0 {} one {, deinen Eintrag,} other {, deine Einträge,}} und alle deine Daten", + "deleteConfirm": "{count, plural, =0 {Ja, mein Konto löschen} one {Ja, mein Konto und meinen Eintrag löschen} other {Ja, mein Konto und meine Einträge löschen}}", + "deleteDialog": "Möchtest du dein Konto wirklich löschen?{count, plural, =0 {} one { Dein Eintrag wird ebenfalls gelöscht.} other { Deine Einträge werden ebenfalls gelöscht.}}" + } + }, + "Auth": { + "complete": { + "title": "Du wirst angemeldet...", + "body": "Wir bestätigen deinen Link sicher. Du wirst gleich weitergeleitet." + }, + "forgotPassword": { + "title": "Passwort vergessen", + "sentTitle": "E-Mail gesendet", + "body": "Das passiert uns allen. Gib unten deine E-Mail ein, um einen Link zum Zurücksetzen des Passworts zu erhalten." + }, + "resetPassword": { + "title": "Passwort zurücksetzen", + "successTitle": "Passwort aktualisiert", + "body": "Gib unten dein neues Passwort ein.", + "newPassword": "Neues Passwort", + "confirmPassword": "Passwort bestätigen" + }, + "signIn": { + "title": "Bei Peels anmelden", + "firstTime": "Zum ersten Mal hier? Registrieren", + "forgotPassword": "Passwort vergessen?" + }, + "signUp": { + "firstName": "Vorname", + "newPassword": "Dein neues Passwort", + "newsletterOptIn": "Sendet mir gelegentliche E-Mail-Updates über Peels", + "errorWithSupport": "{error} Wenn du denkst, dass das nicht stimmen kann, schreib uns eine E-Mail." + }, + "turnstile": { + "expired": "Die Verifizierung ist abgelaufen. Bitte schließe sie erneut ab.", + "timeout": "Die Sicherheitsprüfung wurde nicht abgeschlossen. Deaktiviere Werbeblocker und versuche es erneut. Wenn es weiterhin fehlschlägt, probiere einen anderen Browser oder ein anderes Netzwerk.", + "unsupported": "Dieser Browser kann die Sicherheitsprüfung nicht abschließen. Bitte probiere einen anderen Browser oder ein anderes Netzwerk.", + "failed": "Die Sicherheitsverifizierung ist mit Fehler #{code} fehlgeschlagen. Bitte versuche es erneut oder nutze einen anderen Browser." + } + }, + "Listings": { + "new": { + "listingTypeTitle": "Welche Art von Eintrag?", + "hostTypeTitle": "Wo nimmst du Essensreste an?", + "listingTypeLabel": "Eintragstyp", + "hostTypeLabel": "Gastgebertyp", + "options": { + "host": { + "title": "Ich nehme Essensreste an", + "description": "Andere können eine Abgabe von Essensresten bei dir zu Hause oder in deinem Gemeinschaftsgarten vereinbaren" + }, + "business": { + "title": "Mein Unternehmen spendet Reste", + "description": "Andere können Kaffeesatz aus deinem Café, Hopfen aus deiner Brauerei oder Ähnliches abholen" + }, + "residential": { + "title": "Bei mir zu Hause", + "description": "Ich nehme Reste an einer Wohnadresse an" + }, + "community": { + "title": "An einem Gemeinschaftsort", + "description": "Ich betreue einen Gemeinschaftsgarten oder etwas Ähnliches" + } + } + }, + "edit": { + "title": "Eintrag bearbeiten", + "notFound": "Eintrag nicht gefunden" + }, + "form": { + "basics": "Grundlagen", + "placeName": "Name des Ortes", + "placeNamePlaceholder": "Name deines {type}", + "yourFirstName": "Dein Vorname", + "location": "Standort", + "selectCountry": "Land auswählen", + "locationPlaceholder": "Deine Straße oder ein Ort in der Nähe", + "customLocation": "Benutzerdefinierter Standort", + "locationHint": "Beginne zu tippen und wähle dann einen der vorgeschlagenen {kind} aus dem Menü.", + "dragPinHint": "Ziehe die Markierung, um deinen Standort zu verfeinern{obscure, select, true { oder zu verschleiern} other {}}.", + "businessAddress": "Adresse deines Unternehmens", + "communityAddress": "Adresse deiner Gemeinschaft", + "residentialAddress": "Deine Straße oder Nachbarschaft", + "donationDetails": "Details zur Spende", + "donationDetailsPlaceholder": "Details zu deiner Spende", + "donationDetailsHint": "Welche Reste du abgeben möchtest und wie die Abholung funktioniert. Links gehören in den eigenen Abschnitt weiter unten.", + "descriptionLabel": "Kurze Beschreibung oder Hinweise", + "descriptionPlaceholder": "Über deinen {type}", + "communityDescriptionHint": "Öffnungszeiten und Kompostiermöglichkeiten. Hebe die akzeptierten Reste und Links für die eigenen Abschnitte weiter unten auf.", + "residentialDescriptionHint": "Dein Kompost-Setup und deine allgemeine Verfügbarkeit. Hebe die akzeptierten Reste für den eigenen Abschnitt weiter unten auf.", + "compostingDetails": "Kompostierungsdetails", + "compostingDetailsHint": "Sei konkret, damit andere genau wissen, was vermieden werden sollte. Gib Elemente einzeln ein, damit sie leichter zu lesen sind.", + "acceptedLabel": "Welche Reste nimmst du an?", + "rejectedLabel": "Welche Reste nimmst du nicht an?", + "addItem": "Element hinzufügen", + "addAnotherItem": "Weiteres Element hinzufügen", + "acceptedPlaceholder": "Ein Element, das du annimmst (z. B. ‘Obstschalen’)", + "acceptedSecondaryPlaceholder": "Ein weiteres Element, das du annimmst", + "rejectedPlaceholder": "Ein Element, das du nicht annimmst (z. B. ‘Fleisch’)", + "rejectedSecondaryPlaceholder": "Ein weiteres Element, das du nicht annimmst", + "media": "Medien", + "mediaHint": "Zeige Peels-Mitgliedern optional {subject}.", + "mediaResidential": "etwas mehr über deinen Eintrag", + "mediaCommunity": "etwas mehr über dein Gemeinschaftsprojekt", + "mediaBusiness": "dein Unternehmen", + "externalLinks": "Externe Links", + "addLink": "Link hinzufügen", + "linkPlaceholder": "Deine Website oder Social Media", + "visibility": "Sichtbarkeit", + "visibilityHint": "Brauchst du eine Pause von Peels? Blende diesen Eintrag vorübergehend von der Karte aus.", + "mapVisibility": "Sichtbarkeit auf der Karte", + "showOnMap": "Diesen Eintrag auf der Karte anzeigen", + "hideFromMap": "Diesen Eintrag von der Karte ausblenden", + "adminHint": "Admin-only controls for this listing.", + "stubSettings": "Stub-Einstellungen", + "regularListing": "Dies ist ein regulärer Eintrag, der dir gehört", + "stubListing": "Dieser Eintrag ist ein Stub, den andere beanspruchen können", + "stubActiveHint": "Dieser Eintrag enthält keine deiner Kontaktinformationen. Andere können ihn als eigenen Eintrag beanspruchen und übernehmen.", + "stubInactiveHint": "Dieser Eintrag wird wie jeder andere angezeigt." + }, + "read": { + "contact": "{name} kontaktieren", + "about": "Über", + "donationDetails": "Details zur Spende", + "accepted": "Was angenommen wird", + "rejected": "Was nicht angenommen wird", + "location": "Standort", + "residentialLocation": "{name} wohnt in {area}. Frage nach dem genauen Standort, wenn ihr die Abgabe von Essensresten vereinbart.", + "nonResidentialLocation": "{name} ist {type} in {area}.", + "thisArea": "dieser Gegend", + "businessType": "ein Unternehmen", + "communityType": "ein Gemeinschaftsort", + "signInForPhotos": "Melde dich an, um die Fotos dieses Gastgebers zu sehen.", + "ownerNote": "Dies ist dein eigener Eintrag{stub, select, true {, als Stub markiert} other {}}. {visibility, select, true {Sieht gut aus!} other {Du hast ihn von der Karte ausgeblendet, daher kannst im Moment nur du ihn sehen.}}", + "stubNote": "Dies ist ein Stub, der vom Peels-Team erstellt wurde. Prüfe die Angaben im Eintrag, bevor du vorbeischaust.", + "stubClaim": "Bist du die Eigentümerin oder der Eigentümer? Kontaktiere uns, um diesen Eintrag zu beanspruchen oder Änderungen anzufordern.", + "firstTime": "Zum ersten Mal hier? Registrieren", + "residentOf": "Bewohner von {area}", + "localResident": "Lokale Person", + "communityIn": "Gemeinschaft in {area}", + "localCommunity": "Lokale Gemeinschaft", + "businessIn": "Unternehmen in {area}", + "localBusiness": "Lokales Unternehmen", + "avatarAlt": "Der Avatar für diesen Eintrag" + }, + "delete": { + "confirm": "Ja, Eintrag löschen", + "dialog": "Möchtest du deinen Eintrag wirklich löschen? Das kann nicht rückgängig gemacht werden.", + "success": "Dein Eintrag wurde gelöscht." + }, + "photos": { + "alt": "Foto {number}", + "dropHere": "Fotos hier ablegen", + "tooMany": "Du kannst höchstens {max} Fotos hochladen", + "tooLargeOne": "Dein Foto ist zu groß. Die maximale Dateigröße beträgt {max} MB.", + "tooLargeMany": "Ein oder mehrere Fotos sind zu groß. Die maximale Dateigröße beträgt {max} MB pro Foto." + } + }, + "Upload": { + "avatarAlt": "Dein Avatar", + "avatarHint": "Lade ein Foto hoch, damit Mitglieder wissen, wem sie schreiben." + }, + "Legal": { + "agreement": "Ich habe die Peels-Nutzungsbedingungen und die Datenschutzerklärung gelesen und stimme ihnen zu", + "privacy": "Datenschutz", + "terms": "Bedingungen" + }, + "Chat": { + "send": "Senden", + "placeholder": "Nachricht{name, select, empty {} other { an {name}}} senden...", + "empty": "Noch keine Nachrichten", + "threadsTitle": "Chats", + "noChats": "Noch keine Chats", + "youReachedOut": "Du hast {name} kontaktiert", + "personReachedOut": "{name} hat dich kontaktiert", + "personReachedOutAbout": "{name} hat dich wegen {listing} kontaktiert", + "drawerTitle": "Chat-Schublade", + "drawerDescription": "Unterhaltung zu diesem Eintrag.", + "report": "Melden oder blockieren", + "reportTitle": "Wir kümmern uns darum", + "reportBody": "Es tut uns leid, dass du Probleme mit {name} hast. Bitte kontaktiere uns, um das Problem zu melden oder diese Person daran zu hindern, dich weiter zu kontaktieren." + }, + "Map": { + "drawerTitle": "Eintragsdetails", + "drawerDescription": "Details zum ausgewählten Eintrag.", + "emptyTitle": "Nichts gefunden", + "emptyBody": "Der Eintrag, den du suchst, existiert nicht oder wurde entfernt. Tut uns leid.", + "searchPlaceholder": "Suchen", + "searchError": "Etwas ist schiefgelaufen. Erneut versuchen?", + "searchNoResults": "Keine Ergebnisse. Tippe weiter oder verfeinere deine Suche", + "didYouKnow": "Wusstest du schon?", + "steps": { + "find": { + "title": "Gastgeber finden", + "description": "Wähle eine Markierung auf der Karte aus." + }, + "contact": { + "title": "Kontakt aufnehmen", + "description": "Vereinbare eine Abgabe per Chat." + }, + "dropOff": { + "title": "Abgeben", + "description": "Lerne deine Nachbarn kennen!" + } + } + }, + "Newsletter": { + "title": "Newsletter", + "inboxTitle": "Direkt in dein Postfach", + "inboxDescription": "Aktiviere den Empfang künftiger Newsletter-Ausgaben per E-Mail.", + "rss": "Oder abonniere den RSS-Feed.", + "latestIssue": "Neueste Ausgabe", + "pastIssues": "Frühere Ausgaben", + "issueSubtitle": "Ausgabe #{number} · Veröffentlicht am {date}", + "parent": "Newsletter", + "stampAlt": "Eine Briefmarke", + "aside": { + "title": "Über diesen Newsletter", + "bodyGuest": "Dies ist die Webversion des E-Mail-Newsletters für Abonnenten. Tritt Peels bei, um künftige Ausgaben zu erhalten.", + "bodySubscribed": "Dies ist die Webversion des E-Mail-Newsletters für Abonnenten. Teile ihn gerne weiter.", + "bodyMember": "Dies ist die Webversion des E-Mail-Newsletters für Abonnenten. Bearbeite deine Einstellungen, um künftige Ausgaben zu erhalten." + }, + "callout": { + "guestTitle": "Tritt Peels bei, um den Newsletter zu erhalten", + "guestBody": "Du musst Mitglied bei Peels sein, um den Newsletter per E-Mail zu erhalten. Die Registrierung ist kostenlos und dauert nur ein paar Sekunden.", + "alreadySubscribedTitle": "Du bist bereits abonniert", + "alreadySubscribedBody": "Die nächste Ausgabe sollte in deinem E-Mail-Postfach erscheinen. Teile diese Seite in der Zwischenzeit gerne mit einer Freundin oder einem Freund!", + "notSubscribedTitle": "Du bist nicht abonniert", + "notSubscribedBody": "Ändere deine Newsletter-Einstellung auf deiner Profilseite.", + "editPreference": "Newsletter-Einstellung bearbeiten" + } + }, + "NotFound": { + "body": "Entschuldigung, wir konnten die gesuchte Seite nicht finden." }, "Support": { "title": "Support", - "subtitle": "We periodically update this page with answers to common questions. Feel free to email us for help with anything else.", + "subtitle": "Wir aktualisieren diese Seite regelmäßig mit Antworten auf häufige Fragen. Für alles Weitere kannst du uns gerne kontaktieren.", "peelsFaq": { "title": "About Peels", "whosBehind": { diff --git a/messages/en.json b/messages/en.json index f1bfb350..a22b546f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6,12 +6,110 @@ "about": "About" }, "Actions": { + "add": "Add", + "addListing": "Add listing", + "addMorePhotos": "Add more photos", + "addPhotos": "Add photos", + "backToPeels": "Back to Peels", + "cancel": "Cancel", + "close": "Close", + "continue": "Continue", + "delete": "Delete", + "deleteListing": "Delete listing", + "done": "Done", + "edit": "Edit", + "emailLink": "Email me the link", + "exportData": "Export data", + "home": "Home", + "joinPeels": "Join Peels", + "noCancel": "No, cancel", + "openInAppleMaps": "Open in Apple Maps", + "openInGoogleMaps": "Open in Google Maps", + "replace": "Replace", + "resetPassword": "Reset password", + "saveChanges": "Save changes", + "seeNearbyListings": "See nearby listings", + "sendLink": "Send the link", + "signIn": "Sign in", + "signInToContact": "Sign in to contact", + "signOut": "Sign out", "signUp": "Sign up", - "signIn": "Sign in" + "update": "Update", + "viewFullListing": "View full listing", + "viewListing": "View listing" + }, + "Common": { + "account": "Account", + "actions": "Actions", + "admin": "Admin", + "email": "Email", + "hidden": "Hidden", + "links": "Links", + "listings": "Listings", + "loading": "Loading...", + "newsletter": "Newsletter", + "notSubscribed": "Not subscribed", + "optional": "optional", + "password": "Password", + "photos": "Photos", + "source": "Source", + "stub": "Stub", + "subscribed": "Subscribed" + }, + "Status": { + "adding": "Adding...", + "copying": "Copying...", + "deleting": "Deleting...", + "emailing": "Emailing...", + "resetting": "Resetting...", + "saving": "Saving...", + "sending": "Sending...", + "signingIn": "Signing in...", + "signingOut": "Signing out...", + "signingUp": "Signing up...", + "updating": "Updating...", + "uploading": "Uploading...", + "verifying": "Verifying...", + "working": "Working..." + }, + "Errors": { + "alreadyYourEmail": "This is already your email address.", + "emptyListingName": "You can't have an empty {type} name.", + "emptyName": "You can’t have an empty name.", + "accountDeleted": "Your account has been deleted. Sorry to see you go.", + "accountExists": "An account with this email already exists. Please sign in instead.", + "deleteAccountFailed": "Error whilst deleting account", + "duplicateListing": "An identical listing already exists.", + "emailRequired": "Email is required", + "failedDeleteListing": "Failed to delete listing.", + "failedDeletePhoto": "Failed to delete photo. Please try again.", + "generic": "Hmm, something’s not right. Mind trying again?", + "genericLater": "Something went wrong. Please try again later.", + "missingLocation": "Please select a location.", + "missingSignUpFields": "A first name, email, and password are required.", + "passwordMismatch": "Those passwords don’t match.", + "photoUploadFailed": "There was an error uploading your photos. Please try again.", + "avatarUploadFailed": "There was an error uploading your photo. Please try again.", + "requiredPasswordFields": "Both those fields are required.", + "resetPasswordDenied": "Hmm, something’s not right. You might not have permission to reset this password, or you tried reusing a recent password.", + "resetPasswordSuccess": "Your password has been updated. Let’s get back to composting!", + "savePhotosFailed": "Created listing but couldn’t save photos.", + "signUpFailed": "Sign up failed", + "tooManyListings": "You’ve reached the maximum number of listings allowed. Delete one of your current three to create a new one.", + "tooManyMessages": "You’ve sent too many messages. Please try again later.", + "updateEmailFailed": "Hmm, something’s not right. Check your email or try again.", + "updateFirstNameFailed": "Sorry, we couldn’t update your first name.", + "updateNewsletterFailed": "Sorry, we couldn’t update your newsletter preference.", + "unexpected": "An unexpected error occurred. Please try again later.", + "validationSummary": "Please fix the above error{count, plural, one {} other {s}} and then try again.", + "verificationChallenge": "Please complete the verification challenge.", + "verificationFailed": "Verification failed. Please try again.", + "forgotPasswordSuccess": "Check your inbox (and spam) for a password reset link, assuming that email address is linked to a Peels account." }, "Index": { "title": "Find a home for your food scraps, wherever you are", "subtitle": "Peels connects folks with food scraps to those who compost. It’s a free, non-commercial, community project.", + "hostAvatarAlt": "The avatar for a Peels host", "buttons": { "browseMap": "Browse the map", "signUp": "Sign up to Peels", @@ -64,7 +162,266 @@ } }, "Profile": { - "addListing": "Add a listing" + "addListing": "Add a listing", + "addAnotherListing": "Add another listing", + "listingCardAlt": "Your avatar for this {type} listing", + "listingCardType": "{type} listing", + "listingPrompt": "Put yourself, your community spot, or your business on the map", + "sections": { + "listings": "Listings", + "account": "Account", + "actions": "Actions" + }, + "account": { + "firstName": "First name", + "firstNameHint": "Use your first name or a nickname.", + "emailSuccess": "Check your email for the verification link.", + "emailHint": "We’ll send a verification link to this email.", + "newsletterSubscribedHint": "We’ll send you occasional email updates about Peels.", + "newsletterNotSubscribedHint": "We’ll only send you necessary account or listing-related emails." + }, + "actions": { + "signOutTitle": "Sign out", + "signOutDescription": "Goodbye for now!", + "exportTitle": "Export data", + "exportDescription": "Get a copy of your Peels data", + "exportDialogTitle": "Coming soon", + "exportDialog": "We’re still working on this feature. In the meantime, reach out and ask us to export your data manually.", + "deleteTitle": "Delete account", + "deleteDescription": "Delete your account{count, plural, =0 {} one {, listing,} other {, listings,}} and all your data", + "deleteConfirm": "{count, plural, =0 {Yes, delete my account} one {Yes, delete my account and listing} other {Yes, delete my account and listings}}", + "deleteDialog": "Are you sure you want to delete your account?{count, plural, =0 {} one { Your listing will also be deleted.} other { Your listings will also be deleted.}}" + } + }, + "Auth": { + "complete": { + "title": "Signing you in...", + "body": "We’re securely confirming your link. You’ll be redirected in a moment." + }, + "forgotPassword": { + "title": "Forgot password", + "sentTitle": "Email sent", + "body": "It happens to all of us. Enter your email below to receive a password reset link." + }, + "resetPassword": { + "title": "Reset password", + "successTitle": "Password updated", + "body": "Please enter your new password below.", + "newPassword": "New password", + "confirmPassword": "Confirm password" + }, + "signIn": { + "title": "Sign in to Peels", + "firstTime": "First time here? Sign up", + "forgotPassword": "Forgot password?" + }, + "signUp": { + "firstName": "First name", + "newPassword": "Your new password", + "newsletterOptIn": "Send me occasional email updates about Peels", + "errorWithSupport": "{error} If you think this might be wrong, please email us." + }, + "turnstile": { + "expired": "Verification expired. Please complete it again.", + "timeout": "Security check didn’t complete. Try disabling ad blockers and then try again. If it still fails, try a different browser or network.", + "unsupported": "This browser can’t complete the security check. Please try a different browser or network.", + "failed": "Security verification failed with error #{code}. Please try again or use a different browser." + } + }, + "Listings": { + "new": { + "listingTypeTitle": "What kind of listing?", + "hostTypeTitle": "Where will you accept food scraps?", + "listingTypeLabel": "Listing type", + "hostTypeLabel": "Host type", + "options": { + "host": { + "title": "I accept food scraps", + "description": "Others can arrange food scraps drop-off to your home or your community garden" + }, + "business": { + "title": "My business donates scraps", + "description": "Others can pick up spent coffee from your cafe, hops from your brewery, or similar" + }, + "residential": { + "title": "At my home", + "description": "I accept scraps at a residential address" + }, + "community": { + "title": "At a community place", + "description": "I manage a community garden or similar" + } + } + }, + "edit": { + "title": "Edit listing", + "notFound": "Listing not found" + }, + "form": { + "basics": "Basics", + "placeName": "Place name", + "placeNamePlaceholder": "Your {type} name", + "yourFirstName": "Your first name", + "location": "Location", + "selectCountry": "Select a country", + "locationPlaceholder": "Your street name or nearby", + "customLocation": "Custom location", + "locationHint": "Start typing, then select one of the suggested {kind} from the dropdown.", + "dragPinHint": "Drag the pin to refine{obscure, select, true { or obscure} other {}} your location.", + "businessAddress": "Your business’ address", + "communityAddress": "Your community’s address", + "residentialAddress": "Your street or neighbourhood", + "donationDetails": "Donation details", + "donationDetailsPlaceholder": "Your donation details", + "donationDetailsHint": "What kind of scraps you have to give away and the collection details. Save any links for the dedicated section, below.", + "descriptionLabel": "Short description or instructions", + "descriptionPlaceholder": "About your {type}", + "communityDescriptionHint": "Opening hours and composting facilities. Save the scraps you accept and any links for the dedicated sections, below.", + "residentialDescriptionHint": "Your composting set up and general availability. Save the scraps you accept for the dedicated section, below.", + "compostingDetails": "Composting details", + "compostingDetailsHint": "Be specific so people know exactly what should be avoided. Enter items separately so it’s easier to read.", + "acceptedLabel": "What scraps do you accept?", + "rejectedLabel": "What scraps do you not accept?", + "addItem": "Add an item", + "addAnotherItem": "Add another item", + "acceptedPlaceholder": "An item you accept (e.g. ‘fruit rinds’)", + "acceptedSecondaryPlaceholder": "Another item you accept", + "rejectedPlaceholder": "An item you don’t accept (e.g. ‘meat’)", + "rejectedSecondaryPlaceholder": "Another item you don’t accept", + "media": "Media", + "mediaHint": "Optionally show {subject} to Peels members.", + "mediaResidential": "a bit more about your listing", + "mediaCommunity": "a bit more about your community project", + "mediaBusiness": "off your business", + "externalLinks": "External links", + "addLink": "Add link", + "linkPlaceholder": "Your website or social media", + "visibility": "Visibility", + "visibilityHint": "Need a break from Peels? Temporarily hide this listing from the map.", + "mapVisibility": "Map visibility", + "showOnMap": "Show this listing on the map", + "hideFromMap": "Hide this listing from the map", + "adminHint": "Admin-only controls for this listing.", + "stubSettings": "Stub settings", + "regularListing": "This is a regular listing owned by you", + "stubListing": "This listing is a stub that others can claim", + "stubActiveHint": "This listing will not contain your contact information. Others can claim it as their own and take it over.", + "stubInactiveHint": "This listing will be presented just like any other." + }, + "read": { + "contact": "Contact {name}", + "about": "About", + "donationDetails": "Donation details", + "accepted": "What’s accepted", + "rejected": "What’s not", + "location": "Location", + "residentialLocation": "{name} is a resident of {area}. Ask them for their exact location when you arrange a food scrap drop-off.", + "nonResidentialLocation": "{name} is {type} located in {area}.", + "thisArea": "this area", + "businessType": "a business", + "communityType": "a community spot", + "signInForPhotos": "Sign in to see this host’s photos.", + "ownerNote": "This is your own listing{stub, select, true {, marked as a stub} other {}}. {visibility, select, true {Lookin’ good!} other {You’ve hidden it from the map, so only you can see this right now.}}", + "stubNote": "This is a stub created by the Peels team. Double-check the listing information before visiting.", + "stubClaim": "Are you the owner? Reach out to claim this listing or to request changes.", + "firstTime": "First time here? Sign up", + "residentOf": "Resident of {area}", + "localResident": "Local resident", + "communityIn": "Community in {area}", + "localCommunity": "Local community", + "businessIn": "Business in {area}", + "localBusiness": "Local business", + "avatarAlt": "The avatar for this listing" + }, + "delete": { + "confirm": "Yes, delete listing", + "dialog": "Are you sure you want to delete your listing? This is irreversible.", + "success": "Your listing has been deleted." + }, + "photos": { + "alt": "Photo {number}", + "dropHere": "Drop photos here", + "tooMany": "You can only upload up to {max} photos", + "tooLargeOne": "Your photo is too large. The maximum file size is {max}MB.", + "tooLargeMany": "One or more of your photos are too large. The maximum file size is {max}MB per photo." + } + }, + "Upload": { + "avatarAlt": "Your avatar", + "avatarHint": "Consider uploading a photo so members know who they’re messaging." + }, + "Legal": { + "agreement": "I have read and agree to the Peels terms of use and privacy policy", + "privacy": "Privacy", + "terms": "Terms" + }, + "Chat": { + "send": "Send", + "placeholder": "Send a message{name, select, empty {} other { to {name}}}...", + "empty": "No messages yet", + "threadsTitle": "Chats", + "noChats": "No chats yet", + "youReachedOut": "You reached out to {name}", + "personReachedOut": "{name} reached out to you", + "personReachedOutAbout": "{name} reached out to you about {listing}", + "drawerTitle": "Chat drawer", + "drawerDescription": "Conversation for this listing.", + "report": "Report or block", + "reportTitle": "Let’s get this sorted", + "reportBody": "Sorry to hear you’re having trouble with {name}. Please contact us to report the issue or to block them from contacting you any more." + }, + "Map": { + "drawerTitle": "Listing details", + "drawerDescription": "Selected listing details.", + "emptyTitle": "Coming up empty", + "emptyBody": "The listing you’re looking for doesn’t exist or has been removed. Sorry to disappoint.", + "searchPlaceholder": "Search", + "searchError": "Something went wrong. Try again?", + "searchNoResults": "No results. Keep typing or refine your search", + "didYouKnow": "Did you know?", + "steps": { + "find": { + "title": "Find a host", + "description": "Select a marker on the map." + }, + "contact": { + "title": "Contact", + "description": "Arrange a drop-off via chat." + }, + "dropOff": { + "title": "Drop-off", + "description": "Meet your neighbours!" + } + } + }, + "Newsletter": { + "title": "Newsletter", + "inboxTitle": "Get these in your inbox", + "inboxDescription": "Opt-in to receive future issues of the newsletter via email.", + "rss": "Or subscribe to the RSS feed.", + "latestIssue": "Latest issue", + "pastIssues": "Past issues", + "issueSubtitle": "Issue #{number} · Published {date}", + "parent": "Newsletter", + "stampAlt": "A postage stamp", + "aside": { + "title": "About this newsletter", + "bodyGuest": "This is the web version of the email newsletter sent out to subscribers. Join Peels to receive future issues.", + "bodySubscribed": "This is the web version of the email newsletter sent out to subscribers. Feel free to share it far and wide.", + "bodyMember": "This is the web version of the email newsletter sent out to subscribers. Edit your preferences to receive future issues." + }, + "callout": { + "guestTitle": "Join Peels to get the newsletter", + "guestBody": "You need to be a member of Peels to get the newsletter via email. Signing up is free and only takes a few seconds.", + "alreadySubscribedTitle": "You’re already subscribed", + "alreadySubscribedBody": "You should see the next issue appear in your email inbox. Feel free to share this page with a friend in the meantime!", + "notSubscribedTitle": "You’re not subscribed", + "notSubscribedBody": "Change your newsletter preference on your Profile page.", + "editPreference": "Edit newsletter preference" + } + }, + "NotFound": { + "body": "Sorry, we couldn’t find the page you were looking for." }, "Support": { "title": "Support", diff --git a/messages/es.json b/messages/es.json index 1b27ab1e..e0345752 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6,12 +6,110 @@ "about": "Acerca" }, "Actions": { + "add": "Agregar", + "addListing": "Agregar anuncio", + "addMorePhotos": "Agregar más fotos", + "addPhotos": "Agregar fotos", + "backToPeels": "Volver a Peels", + "cancel": "Cancelar", + "close": "Cerrar", + "continue": "Continuar", + "delete": "Eliminar", + "deleteListing": "Eliminar anuncio", + "done": "Listo", + "edit": "Editar", + "emailLink": "Enviarme el enlace", + "exportData": "Exportar datos", + "home": "Inicio", + "joinPeels": "Unirme a Peels", + "noCancel": "No, cancelar", + "openInAppleMaps": "Abrir en Apple Maps", + "openInGoogleMaps": "Abrir en Google Maps", + "replace": "Reemplazar", + "resetPassword": "Restablecer contraseña", + "saveChanges": "Guardar cambios", + "seeNearbyListings": "Ver anuncios cercanos", + "sendLink": "Enviar el enlace", + "signIn": "Iniciar sesión", + "signInToContact": "Inicia sesión para contactar", + "signOut": "Cerrar sesión", "signUp": "Registrarse", - "signIn": "Iniciar sesión" + "update": "Actualizar", + "viewFullListing": "Ver anuncio completo", + "viewListing": "Ver anuncio" + }, + "Common": { + "account": "Cuenta", + "actions": "Acciones", + "admin": "Administración", + "email": "Correo electrónico", + "hidden": "Oculto", + "links": "Enlaces", + "listings": "Anuncios", + "loading": "Cargando...", + "newsletter": "Boletín", + "notSubscribed": "No suscrito", + "optional": "opcional", + "password": "Contraseña", + "photos": "Fotos", + "source": "Fuente", + "stub": "Borrador", + "subscribed": "Suscrito" + }, + "Status": { + "adding": "Agregando...", + "copying": "Copiando...", + "deleting": "Eliminando...", + "emailing": "Enviando correo...", + "resetting": "Restableciendo...", + "saving": "Guardando...", + "sending": "Enviando...", + "signingIn": "Iniciando sesión...", + "signingOut": "Cerrando sesión...", + "signingUp": "Registrando...", + "updating": "Actualizando...", + "uploading": "Subiendo...", + "verifying": "Verificando...", + "working": "Procesando..." + }, + "Errors": { + "alreadyYourEmail": "Esta ya es tu dirección de correo electrónico.", + "emptyListingName": "No puedes dejar vacío el nombre de {type}.", + "emptyName": "No puedes dejar el nombre vacío.", + "accountDeleted": "Tu cuenta ha sido eliminada. Sentimos verte partir.", + "accountExists": "Ya existe una cuenta con este correo. Inicia sesión en su lugar.", + "deleteAccountFailed": "Error al eliminar la cuenta", + "duplicateListing": "Ya existe un anuncio idéntico.", + "emailRequired": "El correo electrónico es obligatorio", + "failedDeleteListing": "No se pudo eliminar el anuncio.", + "failedDeletePhoto": "No se pudo eliminar la foto. Inténtalo de nuevo.", + "generic": "Hmm, algo no está bien. ¿Puedes intentarlo de nuevo?", + "genericLater": "Algo salió mal. Inténtalo de nuevo más tarde.", + "missingLocation": "Selecciona una ubicación.", + "missingSignUpFields": "El nombre, el correo electrónico y la contraseña son obligatorios.", + "passwordMismatch": "Las contraseñas no coinciden.", + "photoUploadFailed": "Hubo un error al subir tus fotos. Inténtalo de nuevo.", + "avatarUploadFailed": "Hubo un error al subir tu foto. Inténtalo de nuevo.", + "requiredPasswordFields": "Ambos campos son obligatorios.", + "resetPasswordDenied": "Hmm, algo no está bien. Puede que no tengas permiso para restablecer esta contraseña o que hayas intentado reutilizar una contraseña reciente.", + "resetPasswordSuccess": "Tu contraseña ha sido actualizada. ¡Volvamos al compostaje!", + "savePhotosFailed": "Se creó el anuncio, pero no se pudieron guardar las fotos.", + "signUpFailed": "No se pudo completar el registro", + "tooManyListings": "Has alcanzado el número máximo de anuncios permitidos. Elimina uno de tus tres anuncios actuales para crear uno nuevo.", + "tooManyMessages": "Has enviado demasiados mensajes. Inténtalo de nuevo más tarde.", + "updateEmailFailed": "Hmm, algo no está bien. Revisa tu correo o inténtalo de nuevo.", + "updateFirstNameFailed": "Lo sentimos, no pudimos actualizar tu nombre.", + "updateNewsletterFailed": "Lo sentimos, no pudimos actualizar tu preferencia del boletín.", + "unexpected": "Ocurrió un error inesperado. Inténtalo de nuevo más tarde.", + "validationSummary": "Corrige {count, plural, one {el error anterior} other {los errores anteriores}} y vuelve a intentarlo.", + "verificationChallenge": "Completa la verificación.", + "verificationFailed": "La verificación falló. Inténtalo de nuevo.", + "forgotPasswordSuccess": "Revisa tu bandeja de entrada (y spam) para ver el enlace de restablecimiento de contraseña, suponiendo que ese correo esté vinculado a una cuenta de Peels." }, "Index": { "title": "Encuentra un hogar para tus restos de comida, estés donde estés", "subtitle": "Peels conecta a personas con restos de comida con quienes hacen compost. Es un proyecto comunitario, gratuito y sin fines de lucro.", + "hostAvatarAlt": "El avatar de un anfitrión de Peels", "buttons": { "browseMap": "Explorar el mapa", "signUp": "Registrarse en Peels", @@ -64,11 +162,270 @@ } }, "Profile": { - "addListing": "Agregar un anuncio" + "addListing": "Agregar un anuncio", + "addAnotherListing": "Agregar otro anuncio", + "listingCardAlt": "Tu avatar para este anuncio de {type}", + "listingCardType": "Anuncio de {type}", + "listingPrompt": "Ponte a ti, a tu espacio comunitario o a tu negocio en el mapa", + "sections": { + "listings": "Anuncios", + "account": "Cuenta", + "actions": "Acciones" + }, + "account": { + "firstName": "Nombre", + "firstNameHint": "Usa tu nombre o un apodo.", + "emailSuccess": "Revisa tu correo para ver el enlace de verificación.", + "emailHint": "Enviaremos un enlace de verificación a este correo.", + "newsletterSubscribedHint": "Te enviaremos actualizaciones ocasionales por correo sobre Peels.", + "newsletterNotSubscribedHint": "Solo te enviaremos correos necesarios relacionados con tu cuenta o tus anuncios." + }, + "actions": { + "signOutTitle": "Cerrar sesión", + "signOutDescription": "¡Hasta pronto!", + "exportTitle": "Exportar datos", + "exportDescription": "Obtén una copia de tus datos de Peels", + "exportDialogTitle": "Próximamente", + "exportDialog": "Todavía estamos trabajando en esta función. Mientras tanto, contáctanos y pídenos que exportemos tus datos manualmente.", + "deleteTitle": "Eliminar cuenta", + "deleteDescription": "Elimina tu cuenta{count, plural, =0 {} one {, anuncio,} other {, anuncios,}} y todos tus datos", + "deleteConfirm": "{count, plural, =0 {Sí, eliminar mi cuenta} one {Sí, eliminar mi cuenta y anuncio} other {Sí, eliminar mi cuenta y anuncios}}", + "deleteDialog": "¿Seguro que quieres eliminar tu cuenta?{count, plural, =0 {} one { Tu anuncio también se eliminará.} other { Tus anuncios también se eliminarán.}}" + } + }, + "Auth": { + "complete": { + "title": "Iniciando sesión...", + "body": "Estamos confirmando tu enlace de forma segura. Te redirigiremos en un momento." + }, + "forgotPassword": { + "title": "Olvidé mi contraseña", + "sentTitle": "Correo enviado", + "body": "Nos pasa a todos. Ingresa tu correo para recibir un enlace de restablecimiento de contraseña." + }, + "resetPassword": { + "title": "Restablecer contraseña", + "successTitle": "Contraseña actualizada", + "body": "Ingresa tu nueva contraseña abajo.", + "newPassword": "Nueva contraseña", + "confirmPassword": "Confirmar contraseña" + }, + "signIn": { + "title": "Iniciar sesión en Peels", + "firstTime": "¿Primera vez por aquí? Regístrate", + "forgotPassword": "¿Olvidaste tu contraseña?" + }, + "signUp": { + "firstName": "Nombre", + "newPassword": "Tu nueva contraseña", + "newsletterOptIn": "Envíenme actualizaciones ocasionales por correo sobre Peels", + "errorWithSupport": "{error} Si crees que puede ser un error, envíanos un correo." + }, + "turnstile": { + "expired": "La verificación caducó. Complétala de nuevo.", + "timeout": "La comprobación de seguridad no se completó. Prueba desactivar bloqueadores de anuncios y vuelve a intentarlo. Si sigue fallando, prueba otro navegador o red.", + "unsupported": "Este navegador no puede completar la comprobación de seguridad. Prueba otro navegador o red.", + "failed": "La verificación de seguridad falló con el error #{code}. Inténtalo de nuevo o usa otro navegador." + } + }, + "Listings": { + "new": { + "listingTypeTitle": "¿Qué tipo de anuncio?", + "hostTypeTitle": "¿Dónde aceptarás restos de comida?", + "listingTypeLabel": "Tipo de anuncio", + "hostTypeLabel": "Tipo de anfitrión", + "options": { + "host": { + "title": "Acepto restos de comida", + "description": "Otras personas pueden coordinar la entrega de restos de comida en tu casa o huerta comunitaria" + }, + "business": { + "title": "Mi negocio dona restos", + "description": "Otras personas pueden recoger café usado de tu cafetería, lúpulo de tu cervecería o algo similar" + }, + "residential": { + "title": "En mi casa", + "description": "Acepto restos en una dirección residencial" + }, + "community": { + "title": "En un espacio comunitario", + "description": "Gestiono una huerta comunitaria o algo similar" + } + } + }, + "edit": { + "title": "Editar anuncio", + "notFound": "Anuncio no encontrado" + }, + "form": { + "basics": "Datos básicos", + "placeName": "Nombre del lugar", + "placeNamePlaceholder": "Nombre de tu {type}", + "yourFirstName": "Tu nombre", + "location": "Ubicación", + "selectCountry": "Selecciona un país", + "locationPlaceholder": "Tu calle o un lugar cercano", + "customLocation": "Ubicación personalizada", + "locationHint": "Empieza a escribir y selecciona una de las {kind} sugeridas en el menú.", + "dragPinHint": "Arrastra el pin para ajustar{obscure, select, true { u ocultar} other {}} tu ubicación.", + "businessAddress": "Dirección de tu negocio", + "communityAddress": "Dirección de tu comunidad", + "residentialAddress": "Tu calle o barrio", + "donationDetails": "Detalles de la donación", + "donationDetailsPlaceholder": "Detalles de tu donación", + "donationDetailsHint": "Qué tipo de restos tienes para donar y los detalles de recogida. Guarda los enlaces para la sección dedicada, más abajo.", + "descriptionLabel": "Descripción breve o instrucciones", + "descriptionPlaceholder": "Acerca de tu {type}", + "communityDescriptionHint": "Horarios de apertura e instalaciones de compostaje. Guarda los restos que aceptas y cualquier enlace para las secciones dedicadas, más abajo.", + "residentialDescriptionHint": "Tu sistema de compostaje y disponibilidad general. Guarda los restos que aceptas para la sección dedicada, más abajo.", + "compostingDetails": "Detalles de compostaje", + "compostingDetailsHint": "Sé específico para que la gente sepa exactamente qué evitar. Ingresa los elementos por separado para que sean más fáciles de leer.", + "acceptedLabel": "¿Qué restos aceptas?", + "rejectedLabel": "¿Qué restos no aceptas?", + "addItem": "Agregar un elemento", + "addAnotherItem": "Agregar otro elemento", + "acceptedPlaceholder": "Un elemento que aceptas (p. ej. ‘cáscaras de fruta’)", + "acceptedSecondaryPlaceholder": "Otro elemento que aceptas", + "rejectedPlaceholder": "Un elemento que no aceptas (p. ej. ‘carne’)", + "rejectedSecondaryPlaceholder": "Otro elemento que no aceptas", + "media": "Multimedia", + "mediaHint": "Opcionalmente muestra {subject} a miembros de Peels.", + "mediaResidential": "un poco más sobre tu anuncio", + "mediaCommunity": "un poco más sobre tu proyecto comunitario", + "mediaBusiness": "tu negocio", + "externalLinks": "Enlaces externos", + "addLink": "Agregar enlace", + "linkPlaceholder": "Tu sitio web o redes sociales", + "visibility": "Visibilidad", + "visibilityHint": "¿Necesitas un descanso de Peels? Oculta temporalmente este anuncio del mapa.", + "mapVisibility": "Visibilidad en el mapa", + "showOnMap": "Mostrar este anuncio en el mapa", + "hideFromMap": "Ocultar este anuncio del mapa", + "adminHint": "Controles solo para administradores de este anuncio.", + "stubSettings": "Configuración de borrador", + "regularListing": "Este es un anuncio normal de tu propiedad", + "stubListing": "Este anuncio es un borrador que otras personas pueden reclamar", + "stubActiveHint": "Este anuncio no contendrá tu información de contacto. Otras personas pueden reclamarlo como propio y hacerse cargo.", + "stubInactiveHint": "Este anuncio se presentará como cualquier otro." + }, + "read": { + "contact": "Contactar con {name}", + "about": "Acerca de", + "donationDetails": "Detalles de la donación", + "accepted": "Qué se acepta", + "rejected": "Qué no", + "location": "Ubicación", + "residentialLocation": "{name} vive en {area}. Pídele su ubicación exacta cuando coordinen la entrega de restos de comida.", + "nonResidentialLocation": "{name} es {type} ubicada en {area}.", + "thisArea": "esta zona", + "businessType": "un negocio", + "communityType": "un espacio comunitario", + "signInForPhotos": "Inicia sesión para ver las fotos de este anfitrión.", + "ownerNote": "Este es tu propio anuncio{stub, select, true {, marcado como borrador} other {}}. {visibility, select, true {¡Se ve bien!} other {Lo has ocultado del mapa, así que solo tú puedes verlo ahora.}}", + "stubNote": "Este es un borrador creado por el equipo de Peels. Comprueba la información del anuncio antes de visitar.", + "stubClaim": "¿Eres el propietario? Contáctanos para reclamar este anuncio o pedir cambios.", + "firstTime": "¿Primera vez por aquí? Regístrate", + "residentOf": "Residente de {area}", + "localResident": "Residente local", + "communityIn": "Comunidad en {area}", + "localCommunity": "Comunidad local", + "businessIn": "Negocio en {area}", + "localBusiness": "Negocio local", + "avatarAlt": "El avatar de este anuncio" + }, + "delete": { + "confirm": "Sí, eliminar anuncio", + "dialog": "¿Seguro que quieres eliminar tu anuncio? Esta acción no se puede deshacer.", + "success": "Tu anuncio ha sido eliminado." + }, + "photos": { + "alt": "Foto {number}", + "dropHere": "Suelta las fotos aquí", + "tooMany": "Solo puedes subir hasta {max} fotos", + "tooLargeOne": "Tu foto es demasiado grande. El tamaño máximo de archivo es {max} MB.", + "tooLargeMany": "Una o más fotos son demasiado grandes. El tamaño máximo es {max} MB por foto." + } + }, + "Upload": { + "avatarAlt": "Tu avatar", + "avatarHint": "Considera subir una foto para que los miembros sepan con quién están hablando." + }, + "Legal": { + "agreement": "He leído y acepto los términos de uso y la política de privacidad de Peels", + "privacy": "Privacidad", + "terms": "Términos" + }, + "Chat": { + "send": "Enviar", + "placeholder": "Enviar un mensaje{name, select, empty {} other { a {name}}}...", + "empty": "Aún no hay mensajes", + "threadsTitle": "Chats", + "noChats": "Aún no hay chats", + "youReachedOut": "Contactaste con {name}", + "personReachedOut": "{name} te contactó", + "personReachedOutAbout": "{name} te contactó sobre {listing}", + "drawerTitle": "Panel de chat", + "drawerDescription": "Conversación de este anuncio.", + "report": "Denunciar o bloquear", + "reportTitle": "Vamos a resolverlo", + "reportBody": "Sentimos que estés teniendo problemas con {name}. Contáctanos para denunciar el problema o bloquear que vuelva a contactarte." + }, + "Map": { + "drawerTitle": "Detalles del anuncio", + "drawerDescription": "Detalles del anuncio seleccionado.", + "emptyTitle": "No encontramos nada", + "emptyBody": "El anuncio que buscas no existe o fue eliminado. Sentimos decepcionarte.", + "searchPlaceholder": "Buscar", + "searchError": "Algo salió mal. ¿Intentarlo de nuevo?", + "searchNoResults": "Sin resultados. Sigue escribiendo o ajusta tu búsqueda", + "didYouKnow": "¿Sabías que?", + "steps": { + "find": { + "title": "Busca un anfitrión", + "description": "Selecciona un marcador en el mapa." + }, + "contact": { + "title": "Contacta", + "description": "Coordina una entrega por chat." + }, + "dropOff": { + "title": "Entrega", + "description": "¡Conoce a tus vecinos!" + } + } + }, + "Newsletter": { + "title": "Boletín", + "inboxTitle": "Recíbelo en tu correo", + "inboxDescription": "Activa la opción para recibir futuros números del boletín por correo.", + "rss": "O suscríbete al feed RSS.", + "latestIssue": "Último número", + "pastIssues": "Números anteriores", + "issueSubtitle": "Número #{number} · Publicado el {date}", + "parent": "Boletín", + "stampAlt": "Un sello postal", + "aside": { + "title": "Acerca de este boletín", + "bodyGuest": "Esta es la versión web del boletín enviado por correo a los suscriptores. Únete a Peels para recibir futuros números.", + "bodySubscribed": "Esta es la versión web del boletín enviado por correo a los suscriptores. Siéntete libre de compartirlo.", + "bodyMember": "Esta es la versión web del boletín enviado por correo a los suscriptores. Edita tus preferencias para recibir futuros números." + }, + "callout": { + "guestTitle": "Únete a Peels para recibir el boletín", + "guestBody": "Necesitas ser miembro de Peels para recibir el boletín por correo. Registrarse es gratis y solo toma unos segundos.", + "alreadySubscribedTitle": "Ya estás suscrito", + "alreadySubscribedBody": "El próximo número debería aparecer en tu bandeja de entrada. Mientras tanto, comparte esta página con alguien.", + "notSubscribedTitle": "No estás suscrito", + "notSubscribedBody": "Cambia tu preferencia de boletín en tu página de Perfil.", + "editPreference": "Editar preferencia del boletín" + } + }, + "NotFound": { + "body": "Lo sentimos, no pudimos encontrar la página que buscabas." }, "Support": { "title": "Soporte", - "subtitle": "We periodically update this page with answers to common questions. Feel free to email us for help with anything else.", + "subtitle": "Actualizamos esta página periódicamente con respuestas a preguntas comunes. Si necesitas algo más, puedes contactarnos.", "peelsFaq": { "title": "Acerca de Peels", "whosBehind": { diff --git a/package.json b/package.json index c810e015..ad544a2d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "supabase:reset": "supabase db reset", "seed:local-media": "node scripts/seed-local-media.mjs", "supabase:diff": "supabase db diff", + "i18n:check": "node scripts/check-i18n-messages.mjs", + "check": "npm run i18n:check && npm run format:check", "format": "prettier --write .", "format:check": "prettier --check ." }, diff --git a/scripts/check-i18n-messages.mjs b/scripts/check-i18n-messages.mjs new file mode 100644 index 00000000..5128175c --- /dev/null +++ b/scripts/check-i18n-messages.mjs @@ -0,0 +1,266 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const rootDir = process.cwd(); +const configPath = path.join(rootDir, "src/i18n/config.ts"); +const messagesDir = path.join(rootDir, "messages"); + +function readConfig() { + const source = fs.readFileSync(configPath, "utf8"); + const localesMatch = source.match(/locales\s*=\s*(\[[^\]]+\])\s*as const/); + const defaultLocaleMatch = source.match( + /defaultLocale:\s*Locale\s*=\s*["']([^"']+)["']/ + ); + + if (!localesMatch) { + throw new Error(`Could not find the locales array in ${configPath}`); + } + + const locales = JSON.parse(localesMatch[1].replaceAll("'", '"')); + const defaultLocale = defaultLocaleMatch?.[1] ?? locales[0]; + + if (!locales.includes(defaultLocale)) { + throw new Error( + `Default locale "${defaultLocale}" is not listed in ${configPath}` + ); + } + + return { locales, defaultLocale }; +} + +function readMessages(locale) { + const filePath = path.join(messagesDir, `${locale}.json`); + + if (!fs.existsSync(filePath)) { + throw new Error( + `Missing message file: ${path.relative(rootDir, filePath)}` + ); + } + + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function valueType(value) { + if (Array.isArray(value)) { + return "array"; + } + + if (value === null) { + return "null"; + } + + return typeof value; +} + +function flattenMessages(value, pathParts = [], entries = new Map()) { + if (value && typeof value === "object" && !Array.isArray(value)) { + entries.set(pathParts.join("."), { + type: "object", + value, + }); + + for (const [key, childValue] of Object.entries(value)) { + flattenMessages(childValue, [...pathParts, key], entries); + } + + return entries; + } + + entries.set(pathParts.join("."), { + type: valueType(value), + value, + }); + + return entries; +} + +function leafKeys(entries) { + return [...entries] + .filter(([, entry]) => entry.type !== "object") + .map(([key]) => key) + .sort(); +} + +function extractIcuArguments(message) { + const argumentsSet = new Set(); + + for (let index = 0; index < message.length; index += 1) { + if (message[index] !== "{") { + continue; + } + + const match = message + .slice(index + 1) + .match(/^([A-Za-z_][\w]*)\s*(?:[,}])/); + + if (match) { + argumentsSet.add(match[1]); + } + + let depth = 1; + index += 1; + + while (index < message.length && depth > 0) { + if (message[index] === "{") { + depth += 1; + } else if (message[index] === "}") { + depth -= 1; + } + + index += 1; + } + } + + return [...argumentsSet].sort(); +} + +function extractRichTextTags(message) { + const tags = new Set(); + const tagPattern = /<\/?([A-Za-z][\w]*)\b[^>]*>/g; + + for (const match of message.matchAll(tagPattern)) { + tags.add(match[1]); + } + + return [...tags].sort(); +} + +function listDifference(left, right) { + const rightSet = new Set(right); + return left.filter((item) => !rightSet.has(item)); +} + +function sameList(left, right) { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); +} + +function formatList(items) { + return items.map((item) => `- ${item}`).join("\n"); +} + +function compareLocale(locale, baselineLocale, baselineEntries, localeEntries) { + const problems = []; + const baselineLeaves = leafKeys(baselineEntries); + const localeLeaves = leafKeys(localeEntries); + const missingKeys = listDifference(baselineLeaves, localeLeaves); + const extraKeys = listDifference(localeLeaves, baselineLeaves); + + if (missingKeys.length > 0) { + problems.push( + `messages/${locale}.json is missing keys from messages/${baselineLocale}.json:\n${formatList( + missingKeys + )}` + ); + } + + if (extraKeys.length > 0) { + problems.push( + `messages/${locale}.json has extra keys not found in messages/${baselineLocale}.json:\n${formatList( + extraKeys + )}` + ); + } + + for (const [key, baselineEntry] of baselineEntries) { + const localeEntry = localeEntries.get(key); + + if (!localeEntry) { + continue; + } + + if (baselineEntry.type !== localeEntry.type) { + problems.push( + `messages/${locale}.json has a structural mismatch at "${key}": expected ${baselineEntry.type}, found ${localeEntry.type}` + ); + continue; + } + + if (localeEntry.type !== "string") { + continue; + } + + if (localeEntry.value.trim() === "") { + problems.push(`messages/${locale}.json has an empty string at "${key}"`); + continue; + } + + const baselineArguments = extractIcuArguments(baselineEntry.value); + const localeArguments = extractIcuArguments(localeEntry.value); + + if (!sameList(baselineArguments, localeArguments)) { + problems.push( + `messages/${locale}.json has ICU placeholder mismatch at "${key}": expected {${baselineArguments.join( + ", " + )}}, found {${localeArguments.join(", ")}}` + ); + } + + const baselineTags = extractRichTextTags(baselineEntry.value); + const localeTags = extractRichTextTags(localeEntry.value); + + if (!sameList(baselineTags, localeTags)) { + problems.push( + `messages/${locale}.json has rich-text tag mismatch at "${key}": expected <${baselineTags.join( + ">, <" + )}>, found <${localeTags.join(">, <")}>` + ); + } + } + + return problems; +} + +function main() { + const { locales, defaultLocale } = readConfig(); + const baselineMessages = readMessages(defaultLocale); + const baselineEntries = flattenMessages(baselineMessages); + const problems = []; + + for (const locale of locales) { + const messages = readMessages(locale); + const entries = flattenMessages(messages); + + for (const [key, entry] of entries) { + if (entry.type === "string" && entry.value.trim() === "") { + problems.push( + `messages/${locale}.json has an empty string at "${key}"` + ); + } + } + + if (locale === defaultLocale) { + continue; + } + + problems.push( + ...compareLocale(locale, defaultLocale, baselineEntries, entries) + ); + } + + if (problems.length > 0) { + console.error( + `i18n message check failed with ${problems.length} problem(s):` + ); + console.error(""); + console.error(problems.join("\n\n")); + process.exitCode = 1; + return; + } + + console.log( + `i18n message check passed for ${locales.length} locale(s): ${locales.join( + ", " + )}` + ); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/src/app/(core)/(interact)/(centered)/profile/page.js b/src/app/(core)/(interact)/(centered)/profile/page.js index 22e58012..06f705c8 100644 --- a/src/app/(core)/(interact)/(centered)/profile/page.js +++ b/src/app/(core)/(interact)/(centered)/profile/page.js @@ -7,6 +7,7 @@ import LegalFooter from "@/components/LegalFooter"; import { styled } from "@pigment-css/react"; import { Suspense } from "react"; import Toast from "@/components/Toast"; +import { getTranslations } from "next-intl/server"; export const metadata = { title: "Profile", @@ -14,6 +15,7 @@ export const metadata = { // Keep URL-based feedback in a client leaf so server rendering is driven by auth/data only. export default async function ProfilePage() { + const t = await getTranslations("Profile.sections"); const supabase = await createClient(); // Get the authenticated user first, then fetch profile data in parallel. const { @@ -42,17 +44,17 @@ export default async function ProfilePage() {
-

Listings

+

{t("listings")}

-

Account

+

{t("account")}

-

Actions

+

{t("actions")}

diff --git a/src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx b/src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx index 752615c1..ff687409 100644 --- a/src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx +++ b/src/app/(core)/(static)/newsletter/(issues)/[slug]/page.tsx @@ -12,6 +12,7 @@ import StaticPageSection from "@/components/StaticPageSection"; import HeaderBlock from "@/components/HeaderBlock"; import FooterBlock from "@/components/FooterBlock"; import { getNewsletterIssueImageUrl } from "@/utils/storage"; +import { getTranslations } from "next-intl/server"; type NewsletterIssuePageProps = { params: Promise<{ slug: string }>; @@ -54,6 +55,7 @@ export default async function NewsletterIssuePage({ params, }: NewsletterIssuePageProps) { const { slug } = await params; + const t = await getTranslations("Newsletter"); const { metadata, customMetadata, formattedDate } = await getNewsletterIssueMetadata(slug); const title = customMetadata.verboseTitle @@ -75,8 +77,11 @@ export default async function NewsletterIssuePage({
@@ -85,14 +90,17 @@ export default async function NewsletterIssuePage({ -

Get these in your inbox

+

{t("inboxTitle")}

{siteConfig.newsletter.description}

- Or subscribe to the{" "} - RSS feed. + {t.rich("rss", { + link: (chunks) => ( + {chunks} + ), + })}

diff --git a/src/app/(core)/(static)/newsletter/page.tsx b/src/app/(core)/(static)/newsletter/page.tsx index e604ec54..1c15f6f6 100644 --- a/src/app/(core)/(static)/newsletter/page.tsx +++ b/src/app/(core)/(static)/newsletter/page.tsx @@ -8,6 +8,7 @@ import HeaderBlock from "@/components/HeaderBlock"; import FooterBlock from "@/components/FooterBlock"; import StaticPageMain from "@/components/StaticPageMain"; import { styled } from "@pigment-css/react"; +import { getTranslations } from "next-intl/server"; export const metadata = { title: "Newsletter", @@ -18,12 +19,14 @@ export const metadata = { }, }; -export default function NewsletterPage() { +export default async function NewsletterPage() { + const t = await getTranslations("Newsletter"); + return ( @@ -31,14 +34,17 @@ export default function NewsletterPage() { -

Get these in your inbox

-

Opt-in to receive future issues of the newsletter via email.

+

{t("inboxTitle")}

+

{t("inboxDescription")}

- Or subscribe to the{" "} - RSS feed. + {t.rich("rss", { + link: (chunks) => ( + {chunks} + ), + })}

diff --git a/src/app/(forms)/auth/complete/page.tsx b/src/app/(forms)/auth/complete/page.tsx index 06f6ec11..9b2f828b 100644 --- a/src/app/(forms)/auth/complete/page.tsx +++ b/src/app/(forms)/auth/complete/page.tsx @@ -1,16 +1,17 @@ import Form from "@/components/Form"; import FormHeader from "@/components/FormHeader"; +import { getTranslations } from "next-intl/server"; + +export default async function AuthCompletePage() { + const t = await getTranslations("Auth.complete"); -export default function AuthCompletePage() { return ( <> -

Signing you in...

+

{t("title")}

-

- We’re securely confirming your link. You’ll be redirected in a moment. -

+

{t("body")}

); diff --git a/src/app/(forms)/forgot-password/page.tsx b/src/app/(forms)/forgot-password/page.tsx index 6e2b0b17..ede72cc9 100644 --- a/src/app/(forms)/forgot-password/page.tsx +++ b/src/app/(forms)/forgot-password/page.tsx @@ -8,17 +8,19 @@ import Field from "@/components/Field"; import Input from "@/components/Input"; import Label from "@/components/Label"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; export default async function ForgotPassword(props: { searchParams: Promise; }) { const searchParams = await props.searchParams; + const t = await getTranslations(); if (searchParams.success) { return ( <> -

Email sent

+

{t("Auth.forgotPassword.sentTitle")}

{/* TODO: include address that was emailed, so user can notice any typos */} @@ -30,15 +32,12 @@ export default async function ForgotPassword(props: { return ( <> -

Forgot password

-

- It happens to all of us. Enter your email below to receive a password - reset link. -

+

{t("Auth.forgotPassword.title")}

+

{t("Auth.forgotPassword.body")}

- + - Email me the link + {t("Actions.emailLink")} diff --git a/src/app/(forms)/profile/listings/[slug]/page.js b/src/app/(forms)/profile/listings/[slug]/page.js index 23365d18..f3e773b8 100644 --- a/src/app/(forms)/profile/listings/[slug]/page.js +++ b/src/app/(forms)/profile/listings/[slug]/page.js @@ -2,6 +2,7 @@ import { createClient } from "@/utils/supabase/server"; import FormHeader from "@/components/FormHeader"; import ListingWrite from "@/components/ListingWrite"; +import { getTranslations } from "next-intl/server"; export const metadata = { title: "Edit Listing", @@ -29,6 +30,7 @@ export const metadata = { // Next.js automatically provides params export default async function EditListingPage({ params }) { + const t = await getTranslations(); const { slug } = await params; const supabase = await createClient(); const { @@ -48,13 +50,13 @@ export default async function EditListingPage({ params }) { .single(); if (!listing) { - return
Listing not found
; + return
{t("Listings.edit.notFound")}
; } return ( <> -

Edit listing

+

{t("Listings.edit.title")}

diff --git a/src/app/(forms)/profile/listings/new/page.js b/src/app/(forms)/profile/listings/new/page.js index 7a681ae0..26787342 100644 --- a/src/app/(forms)/profile/listings/new/page.js +++ b/src/app/(forms)/profile/listings/new/page.js @@ -7,44 +7,42 @@ import Form from "@/components/Form"; import FormHeader from "@/components/FormHeader"; import RadioGroup from "@/components/RadioGroup"; import Radio from "@/components/Radio"; +import { useTranslations } from "next-intl"; // Not possible because this page is marked as "use client". Nest children in client components and then add the metadata // export const metadata = { // title: 'Add Listing', // TODO: Generate metadata to include the type of listing, see edit listing page // } -const listingTypes = [ - { - key: "host", - title: "I accept food scraps", - description: - "Others can arrange food scraps drop-off to your home or your community garden", - }, - { - key: "business", - title: "My business donates scraps", - description: - "Others can pick up spent coffee from your cafe, hops from your brewery, or similar", - }, -]; - -const hostTypes = [ - { - key: "residential", - title: "At my home", - description: "I accept scraps at a residential address", - }, - { - key: "community", - title: "At a community place", - description: "I manage a community garden or similar", - }, -]; - function AddListingContent() { + const t = useTranslations(); const router = useRouter(); const searchParams = useSearchParams(); const type = searchParams.get("type"); + const listingTypes = [ + { + key: "host", + title: t("Listings.new.options.host.title"), + description: t("Listings.new.options.host.description"), + }, + { + key: "business", + title: t("Listings.new.options.business.title"), + description: t("Listings.new.options.business.description"), + }, + ]; + const hostTypes = [ + { + key: "residential", + title: t("Listings.new.options.residential.title"), + description: t("Listings.new.options.residential.description"), + }, + { + key: "community", + title: t("Listings.new.options.community.title"), + description: t("Listings.new.options.community.description"), + }, + ]; const [selectedListingType, setSelectedListingType] = useState(null); const [selectedHostType, setSelectedHostType] = useState(null); @@ -86,9 +84,9 @@ function AddListingContent() { <> {type === "host" ? ( -

Where will you accept food scraps?

+

{t("Listings.new.hostTypeTitle")}

) : ( -

What kind of listing?

+

{t("Listings.new.listingTypeTitle")}

)}
@@ -98,7 +96,7 @@ function AddListingContent() { by="title" value={selectedHostType} onChange={setSelectedHostType} - aria-label="Host type" + aria-label={t("Listings.new.hostTypeLabel")} > {hostTypes.map((option) => ( {listingTypes.map((option) => ( - Continue + {t("Actions.continue")} @@ -140,10 +138,16 @@ function AddListingContent() { function AddListingPage() { return ( - Loading...}> + }> ); } +function LoadingFallback() { + const t = useTranslations("Common"); + + return
{t("loading")}
; +} + export default AddListingPage; diff --git a/src/app/(forms)/profile/reset-password/page.tsx b/src/app/(forms)/profile/reset-password/page.tsx index d782d9fc..56b3b397 100644 --- a/src/app/(forms)/profile/reset-password/page.tsx +++ b/src/app/(forms)/profile/reset-password/page.tsx @@ -10,11 +10,13 @@ import Input from "@/components/Input"; import Label from "@/components/Label"; import Form from "@/components/Form"; import Field from "@/components/Field"; +import { getTranslations } from "next-intl/server"; export default async function ResetPassword(props: { searchParams: Promise; }) { const searchParams = await props.searchParams; + const t = await getTranslations(); const headersList = await headers(); const referer = headersList.get("referer") || ""; const isFromProfile = referer.includes("/profile"); @@ -23,13 +25,13 @@ export default async function ResetPassword(props: { return ( <> -

Password updated

+

{t("Auth.resetPassword.successTitle")}

{searchParams.success}

{/* User is authenticated at this point so we can redirect them to a protected route */}
@@ -39,26 +41,30 @@ export default async function ResetPassword(props: { return ( <> -

Reset password

-

Please enter your new password below.

+

{t("Auth.resetPassword.title")}

+

{t("Auth.resetPassword.body")}

- + - + @@ -69,9 +75,9 @@ export default async function ResetPassword(props: { )} - Reset password + {t("Actions.resetPassword")} diff --git a/src/app/(forms)/sign-in/page.tsx b/src/app/(forms)/sign-in/page.tsx index 3d50193e..84d8a339 100644 --- a/src/app/(forms)/sign-in/page.tsx +++ b/src/app/(forms)/sign-in/page.tsx @@ -3,6 +3,7 @@ import FormHeader from "@/components/FormHeader"; import StrongLink from "@/components/StrongLink"; import SignInForm from "@/components/SignInForm"; import FormFooter from "@/components/FormFooter"; +import { getTranslations } from "next-intl/server"; export const metadata = { title: "Sign In", @@ -19,19 +20,22 @@ export default async function SignIn(props: { }>; }) { const searchParams = await props.searchParams; + const t = await getTranslations("Auth.signIn"); return ( <> {/* TODO: Make FormHeader action conditional based on whether this page (which should be a component) is rendered modally or as a page */} -

Sign in to Peels

+

{t("title")}

- First time here? Sign up + {t.rich("firstTime", { + link: (chunks) => {chunks}, + })}

diff --git a/src/app/actions.ts b/src/app/actions.ts index 16906ba7..c80ea86f 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -11,8 +11,10 @@ import { import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; export const signUpAction = async (formData: FormData, request?: Request) => { + const t = await getTranslations("Errors"); const email = formData.get("email")?.toString(); const password = formData.get("password")?.toString(); const firstNameValidation = validateName(formData.get("first_name")); // Trim first name @@ -59,18 +61,12 @@ export const signUpAction = async (formData: FormData, request?: Request) => { const turnstileEnabled = isTurnstileEnabled(); if (!email || !password || !first_name) { - redirectUrl.searchParams.append( - "error", - "A first name, email, and password are required." - ); + redirectUrl.searchParams.append("error", t("missingSignUpFields")); return redirect(redirectUrl.toString()); } if (turnstileEnabled && !captchaToken) { - redirectUrl.searchParams.append( - "error", - "Please complete the verification challenge." - ); + redirectUrl.searchParams.append("error", t("verificationChallenge")); return redirect(redirectUrl.toString()); } @@ -80,7 +76,7 @@ export const signUpAction = async (formData: FormData, request?: Request) => { if (!validationResult.success) { redirectUrl.searchParams.append( "error", - validationResult.error || "Verification failed. Please try again." + validationResult.error || t("verificationFailed") ); return redirect(redirectUrl.toString()); } @@ -96,18 +92,12 @@ export const signUpAction = async (formData: FormData, request?: Request) => { if (authError) { console.error("Error checking email:", authError); - redirectUrl.searchParams.append( - "error", - "Sorry, we couldn’t process your request." - ); + redirectUrl.searchParams.append("error", t("genericLater")); return redirect(redirectUrl.toString()); } if (existingAuthUser) { - redirectUrl.searchParams.append( - "error", - "An account with this email already exists. Please sign in instead." - ); + redirectUrl.searchParams.append("error", t("accountExists")); return redirect(redirectUrl.toString()); } @@ -143,10 +133,7 @@ export const signUpAction = async (formData: FormData, request?: Request) => { error?.message?.includes(pattern) ); if (isHookTimeout) { - redirectUrl.searchParams.append( - "error", - "Hmm, something’s not right. Mind trying again?" - ); + redirectUrl.searchParams.append("error", t("generic")); return redirect(redirectUrl.toString()); } // Back to general error catching @@ -155,7 +142,7 @@ export const signUpAction = async (formData: FormData, request?: Request) => { ); redirectUrl.searchParams.append( "error", - error?.message || "Sign up failed" + error?.message || t("signUpFailed") ); return redirect(redirectUrl.toString()); } @@ -185,13 +172,14 @@ export const signInAction = async (formData: FormData, request: Request) => { // Very similar to the sendPasswordResetEmailAction export const forgotPasswordAction = async (formData: FormData) => { + const t = await getTranslations("Errors"); const email = formData.get("email")?.toString(); const supabase = await createClient(); const origin = (await headers()).get("origin"); const callbackUrl = formData.get("callbackUrl")?.toString(); if (!email) { - return encodedRedirect("error", "/forgot-password", "Email is required"); + return encodedRedirect("error", "/forgot-password", t("emailRequired")); } const { error } = await supabase.auth.resetPasswordForEmail(email, { @@ -202,11 +190,7 @@ export const forgotPasswordAction = async (formData: FormData) => { if (error) { console.error(error.message); - return encodedRedirect( - "error", - "/forgot-password", - "Hmm, something’s not right. Mind trying again?" - ); + return encodedRedirect("error", "/forgot-password", t("generic")); } if (callbackUrl) { @@ -216,11 +200,12 @@ export const forgotPasswordAction = async (formData: FormData) => { return encodedRedirect( "success", "/forgot-password", - "Check your inbox (and spam) for a password reset link, assuming that email address is linked to a Peels account." + t("forgotPasswordSuccess") ); }; export const updateFirstNameAction = async (formData: FormData) => { + const t = await getTranslations("Errors"); const supabase = await createClient(); const { data: { user }, @@ -228,7 +213,7 @@ export const updateFirstNameAction = async (formData: FormData) => { const firstNameValidation = validateName(formData.get("first_name")); if (!firstNameValidation.isValid) { - return { error: firstNameValidation.error }; + return { error: t("emptyName") }; } const { error } = await supabase @@ -240,13 +225,14 @@ export const updateFirstNameAction = async (formData: FormData) => { if (error) { console.error("Error updating first name:", error); - return { error: "Sorry, we couldn’t update your first name." }; + return { error: t("updateFirstNameFailed") }; } return { success: true }; }; export const sendEmailChangeEmailAction = async (formData: FormData) => { + const t = await getTranslations("Errors"); const supabase = await createClient(); const { error } = await supabase.auth.updateUser({ email: formData.get("email") as string, @@ -255,7 +241,7 @@ export const sendEmailChangeEmailAction = async (formData: FormData) => { if (error) { console.error("Error sending email change email:", error); return { - error: "Hmm, something’s not right. Check your email or try again.", + error: t("updateEmailFailed"), }; } @@ -263,6 +249,7 @@ export const sendEmailChangeEmailAction = async (formData: FormData) => { }; export const updateNewsletterPreferenceAction = async (formData: FormData) => { + const t = await getTranslations("Errors"); const supabase = await createClient(); const { data: { user }, @@ -279,7 +266,7 @@ export const updateNewsletterPreferenceAction = async (formData: FormData) => { if (error) { console.error("Error updating newsletter preference:", error); - return { error: "Sorry, we couldn’t update your newsletter preference." }; + return { error: t("updateNewsletterFailed") }; } return { success: true }; @@ -320,6 +307,7 @@ export const updateNewsletterPreferenceAction = async (formData: FormData) => { // Whereas this action actually updates the user's password export const resetPasswordAction = async (formData: FormData) => { + const t = await getTranslations("Errors"); const supabase = await createClient(); const newPassword = formData.get("password") as string; @@ -329,16 +317,12 @@ export const resetPasswordAction = async (formData: FormData) => { encodedRedirect( "error", "/profile/reset-password", - "Both those fields are required." + t("requiredPasswordFields") ); } if (newPassword !== confirmNewPassword) { - encodedRedirect( - "error", - "/profile/reset-password", - "Those passwords don’t match." - ); + encodedRedirect("error", "/profile/reset-password", t("passwordMismatch")); } const { error } = await supabase.auth.updateUser({ @@ -349,14 +333,14 @@ export const resetPasswordAction = async (formData: FormData) => { encodedRedirect( "error", "/profile/reset-password", - "Hmm, something’s not right. You might not have permission to reset this password, or you tried reusing a recent password." + t("resetPasswordDenied") ); } encodedRedirect( "success", "/profile/reset-password", - "Your password has been updated. Let’s get back to composting!" + t("resetPasswordSuccess") ); }; @@ -367,6 +351,7 @@ export const signOutAction = async () => { }; export const deleteListingAction = async (slug: string) => { + const t = await getTranslations(); // Check if user is logged in first const supabase = await createClient(); const { @@ -397,21 +382,22 @@ export const deleteListingAction = async (slug: string) => { if (!response.ok) { console.error("Error deleting listing:", data.message); - return { success: false, message: "Failed to delete listing." }; + return { success: false, message: t("Errors.failedDeleteListing") }; } else { console.log("Listing successfully deleted:", slug); - return { success: true, message: "Your listing has been deleted." }; + return { success: true, message: t("Listings.delete.success") }; } } catch (error) { console.error("Error deleting listing:", error); return { success: false, - message: "Hmm, something’s not right. Mind trying again?", + message: t("Errors.generic"), }; } }; export const deleteAccountAction = async () => { + const t = await getTranslations("Errors"); let redirectPath: string | null = null; const supabase = await createClient(); @@ -442,7 +428,7 @@ export const deleteAccountAction = async () => { // const data = await response.json(); // console.log("Response data:", data); - redirectPath = `/sign-in?success=Your account has been deleted. Sorry to see you go.`; + redirectPath = `/sign-in?success=${encodeURIComponent(t("accountDeleted"))}`; // if (!response.ok) { // console.error("Delete account failed:", data); @@ -450,7 +436,7 @@ export const deleteAccountAction = async () => { // } } catch (error) { console.error("Delete account error:", error); - redirectPath = `/profile?error=Error whilst deleting account`; + redirectPath = `/profile?error=${encodeURIComponent(t("deleteAccountFailed"))}`; } finally { await supabase.auth.signOut(); if (redirectPath) { @@ -517,6 +503,7 @@ export async function fetchListingsInView( } export const createOrUpdateListingAction = async (listingData: any) => { + const t = await getTranslations("Errors"); const supabase = await createClient(); try { @@ -526,7 +513,7 @@ export const createOrUpdateListingAction = async (listingData: any) => { if (listingData.type !== "residential" && listingData.name) { const nameValidation = validateName(listingData.name); if (!nameValidation.isValid) { - return { error: nameValidation.error }; + return { error: t("emptyName") }; } // Use the validated value listingData.name = nameValidation.value; @@ -545,13 +532,12 @@ export const createOrUpdateListingAction = async (listingData: any) => { // Return specific error messages based on error codes if (error.code === "42501") { return { - error: - "You’ve reached the maximum number of listings allowed. Delete one of your current three to create a new one.", + error: t("tooManyListings"), }; } else if (error.code === "23505") { - return { error: "An identical listing already exists." }; + return { error: t("duplicateListing") }; } - return { error: "Something went wrong. Please try again later." }; + return { error: t("genericLater") }; } // If this was a new listing with pending photos, update them @@ -563,7 +549,7 @@ export const createOrUpdateListingAction = async (listingData: any) => { if (updateError) { console.error("Error updating photos:", updateError); - return { error: "Created listing but couldn’t save photos." }; + return { error: t("savePhotosFailed") }; } } @@ -577,6 +563,6 @@ export const createOrUpdateListingAction = async (listingData: any) => { return { data }; } catch (error) { console.error("Unexpected error in createOrUpdateListingAction:", error); - return { error: "An unexpected error occurred. Please try again later." }; + return { error: t("unexpected") }; } }; diff --git a/src/app/not-found.js b/src/app/not-found.js index 7426b5dd..0cc04968 100644 --- a/src/app/not-found.js +++ b/src/app/not-found.js @@ -1,11 +1,14 @@ import Link from "next/link"; +import { getTranslations } from "next-intl/server"; + +export default async function NotFound() { + const t = await getTranslations(); -export default function NotFound() { return (

404

-

Sorry, we couldn’t find the page you were looking for.

- Home +

{t("NotFound.body")}

+ {t("Actions.home")}
); } diff --git a/src/components/AvatarUploadManager/AvatarUploadManager.jsx b/src/components/AvatarUploadManager/AvatarUploadManager.jsx index 5d26359a..1e3f07cf 100644 --- a/src/components/AvatarUploadManager/AvatarUploadManager.jsx +++ b/src/components/AvatarUploadManager/AvatarUploadManager.jsx @@ -4,11 +4,11 @@ import { useState } from "react"; import AvatarUploadView from "@/components/AvatarUploadView"; import { uploadAvatar, deleteAvatar, getAvatarUrl } from "@/utils/mediaUtils"; import Compressor from "compressorjs"; +import { useTranslations } from "next-intl"; const MAX_MB = 10; const MAX_FILE_SIZE = MAX_MB * 1024 * 1024; // 10MB in bytes const MAX_DIMENSION = 1024; // Consider going down to 512 for avatars -const overSizedFileAlertSingular = `Your photo is too large. The maximum file size is ${MAX_MB}MB.`; function AvatarUploadManager({ initialAvatar, @@ -19,7 +19,11 @@ function AvatarUploadManager({ listingType, ...props }) { + const t = useTranslations(); const [avatar, setAvatar] = useState(initialAvatar || ""); + const overSizedFileAlertSingular = t("Listings.photos.tooLargeOne", { + max: MAX_MB, + }); const processAvatar = (file) => { return new Promise((resolve, reject) => { @@ -67,7 +71,7 @@ function AvatarUploadManager({ if (error?.statusCode === "413" || error?.error?.statusCode === "413") { alert(overSizedFileAlertSingular); } else { - alert("There was an error uploading your photo. Please try again."); + alert(t("Errors.avatarUploadFailed")); } } } diff --git a/src/components/AvatarUploadView/AvatarUploadView.tsx b/src/components/AvatarUploadView/AvatarUploadView.tsx index 3a95ad2d..a3288ec4 100644 --- a/src/components/AvatarUploadView/AvatarUploadView.tsx +++ b/src/components/AvatarUploadView/AvatarUploadView.tsx @@ -10,6 +10,7 @@ import Button from "@/components/Button"; import InputHint from "@/components/InputHint"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const StyledField = styled(Field)({ alignItems: "center", @@ -64,6 +65,7 @@ function AvatarUploadView({ inputHintShown = false, listingType, }: AvatarUploadViewProps) { + const t = useTranslations(); // Hidden file input that we'll trigger programmatically const fileInputRef = useRef(null); const [loading, setLoading] = useState(false); @@ -113,12 +115,12 @@ function AvatarUploadView({ - {loading && Uploading...} + {loading && {t("Status.uploading")}} {!avatar ? ( @@ -128,10 +130,10 @@ function AvatarUploadView({ size="small" onClick={handleFileSelect} loading={loading} - loadingText="Uploading..." + loadingText={t("Status.uploading")} disabled={isBusy} > - Add + {t("Actions.add")} ) : ( // Scenario 2 & 3: Has avatar - show menu with options @@ -141,10 +143,12 @@ function AvatarUploadView({ variant="secondary" size="small" loading={loading || isDeleting} - loadingText={loading ? "Uploading..." : "Deleting..."} + loadingText={ + loading ? t("Status.uploading") : t("Status.deleting") + } disabled={isBusy} > - Edit + {t("Actions.edit")} - Replace + {t("Actions.replace")} @@ -167,19 +171,17 @@ function AvatarUploadView({ variant="danger" size="small" loading={isDeleting} - loadingText="Deleting..." + loadingText={t("Status.deleting")} disabled={isBusy} > - Delete + {t("Actions.delete")} )} {inputHintShown && ( - - Consider uploading a photo so members know who they’re messaging. - + {t("Upload.avatarHint")} )} diff --git a/src/components/ButtonToDialog/ButtonToDialog.tsx b/src/components/ButtonToDialog/ButtonToDialog.tsx index d56b8aa2..ca246643 100644 --- a/src/components/ButtonToDialog/ButtonToDialog.tsx +++ b/src/components/ButtonToDialog/ButtonToDialog.tsx @@ -6,6 +6,7 @@ import SubmitButton from "@/components/SubmitButton"; import { styled } from "@pigment-css/react"; import { useState, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; const DialogContent = styled(Dialog.Content)(({ theme }) => ({ background: "white", @@ -66,14 +67,17 @@ function ButtonToDialog({ children, confirmButtonText, confirmLoadingText, - cancelButtonText = "No, cancel", + cancelButtonText, action, onSubmit, // ...props // Setting this on Button (for size etc) seems to stop the dialog from opening }: ButtonToDialogProps) { + const t = useTranslations(); const [isSubmitting, setIsSubmitting] = useState(false); const resolvedConfirmLoadingText = - confirmLoadingText || (variant === "danger" ? "Deleting..." : "Working..."); + confirmLoadingText || + (variant === "danger" ? t("Status.deleting") : t("Status.working")); + const resolvedCancelButtonText = cancelButtonText || t("Actions.noCancel"); const handleSubmit: React.FormEventHandler | undefined = onSubmit @@ -122,7 +126,7 @@ function ButtonToDialog({ )} diff --git a/src/components/ChatComposer/ChatComposer.tsx b/src/components/ChatComposer/ChatComposer.tsx index 7a05fc30..ca0396aa 100644 --- a/src/components/ChatComposer/ChatComposer.tsx +++ b/src/components/ChatComposer/ChatComposer.tsx @@ -1,7 +1,10 @@ +"use client"; + import Textarea from "@/components/Textarea"; import IconButton from "@/components/IconButton"; import { styled } from "@pigment-css/react"; import FormMessage from "@/components/FormMessage"; +import { useTranslations } from "next-intl"; const ChatComposerForm = styled("div")(({ theme }) => ({ display: "flex", @@ -47,6 +50,7 @@ function ChatComposer({ isDemo, isSending = false, }: ChatComposerProps) { + const t = useTranslations(); const isSendDisabled = !message.trim() || (!isDemo && isSending); return ( @@ -55,7 +59,9 @@ function ChatComposer({ diff --git a/src/components/ChatHeader/ChatHeader.jsx b/src/components/ChatHeader/ChatHeader.jsx index 3c0303d8..46292d28 100644 --- a/src/components/ChatHeader/ChatHeader.jsx +++ b/src/components/ChatHeader/ChatHeader.jsx @@ -10,6 +10,7 @@ import ButtonToDialog from "@/components/ButtonToDialog"; import EncodedEmailLink from "@/components/EncodedEmailLink"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const StyledChatHeader = styled("header")(({ theme }) => ({ display: "flex", @@ -107,6 +108,7 @@ const MainContents = styled("div")(({ theme }) => ({ })); function ChatHeader({ thread, listing, user, isDrawer, isDemo }) { + const t = useTranslations(); const router = useRouter(); // TODO: Consolidate with other role, naming logic elsewhere @@ -137,11 +139,9 @@ function ChatHeader({ thread, listing, user, isDrawer, isDemo }) { {isDrawer && ( <> - - Nested chat drawer title visually hidden TODO - + {t("Chat.drawerTitle")} - Test description for aria visually hidden TODO. + {t("Chat.drawerDescription")} @@ -188,7 +188,7 @@ function ChatHeader({ thread, listing, user, isDrawer, isDemo }) { variant="secondary" size="small" > - View listing + {t("Actions.viewListing")} )} @@ -196,17 +196,18 @@ function ChatHeader({ thread, listing, user, isDrawer, isDemo }) { - Sorry to hear you’re having trouble with {otherPersonName}. - Please{" "} - - contact us - {" "} - to report the issue or to block them from contacting you any - more. + {t.rich("Chat.reportBody", { + name: otherPersonName, + link: (chunks) => ( + + {chunks} + + ), + })} diff --git a/src/components/ChatWindow/ChatWindow.tsx b/src/components/ChatWindow/ChatWindow.tsx index 093a0d84..9b3dca64 100644 --- a/src/components/ChatWindow/ChatWindow.tsx +++ b/src/components/ChatWindow/ChatWindow.tsx @@ -12,6 +12,7 @@ import { formatWeekday } from "@/utils/dateUtils"; import { styled } from "@pigment-css/react"; import { useUnreadMessages } from "@/contexts/UnreadMessagesContext"; +import { useTranslations } from "next-intl"; type ChatWindowProps = { isDrawer?: boolean; @@ -100,6 +101,7 @@ const ChatWindow = memo(function ChatWindow({ existingThread = null, isDemo = false, }: ChatWindowProps) { + const t = useTranslations(); // const router = useRouter(); // Move Supabase client creation outside of render const supabase = useMemo(() => (isDemo ? null : createClient()), [isDemo]); @@ -119,9 +121,7 @@ const ChatWindow = memo(function ChatWindow({ // Turn the rate limiting message into something more friendly (original: new row violates row-level security policy for table "chat_messages") if (errorMessage.includes("violates row-level security policy")) { - setMessageSendError( - "You’ve sent too many messages. Please try again later." - ); + setMessageSendError(t("Errors.tooManyMessages")); } else { setMessageSendError(errorMessage); } @@ -361,7 +361,7 @@ const ChatWindow = memo(function ChatWindow({ {messages.length === 0 && ( -

No messages yet

+

{t("Chat.empty")}

)} {messages.length > 0 && @@ -385,11 +385,16 @@ const ChatWindow = memo(function ChatWindow({ {showInitiationHeader && (

{isDemo || message.sender_id === user?.id - ? `You reached out to ${otherPersonName}` + ? t("Chat.youReachedOut", { name: otherPersonName }) : listing?.owner_has_multiple_non_residential_listings && listing?.name - ? `${otherPersonName} reached out to you about ${listing.name}` - : `${otherPersonName} reached out to you`} + ? t("Chat.personReachedOutAbout", { + name: otherPersonName, + listing: listing.name, + }) + : t("Chat.personReachedOut", { + name: otherPersonName, + })}

)} diff --git a/src/components/IntroHeader/IntroHeader.jsx b/src/components/IntroHeader/IntroHeader.jsx index 2978ce98..459b9244 100644 --- a/src/components/IntroHeader/IntroHeader.jsx +++ b/src/components/IntroHeader/IntroHeader.jsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import MapPin from "@/components/MapPin"; import Avatar from "@/components/Avatar"; import { styled, keyframes } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const featuredItems = [ { @@ -123,6 +124,7 @@ const enterMarkerAnimation = keyframes({ }); function IntroHeader() { + const t = useTranslations("Index"); const [itemIndex, setItemIndex] = useState(0); const [prevItemIndex, setPrevItemIndex] = useState(null); @@ -149,7 +151,7 @@ function IntroHeader() { isExiting={true} isDemo={true} src={`/avatars/featured/${featuredItems[prevItemIndex].photo}`} - alt="The avatar for a Peels host" + alt={t("hostAvatarAlt")} size="massive" /> @@ -163,7 +165,7 @@ function IntroHeader() { isExiting={false} isDemo={true} src={`/avatars/featured/${featuredItems[itemIndex].photo}`} - alt="The avatar for a Peels host" + alt={t("hostAvatarAlt")} size="massive" /> diff --git a/src/components/Label/Label.jsx b/src/components/Label/Label.jsx index 89a86222..03113124 100644 --- a/src/components/Label/Label.jsx +++ b/src/components/Label/Label.jsx @@ -1,5 +1,6 @@ import { Label as HeadlessLabel } from "@headlessui/react"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const StyledLabel = styled(HeadlessLabel)(({ theme }) => ({ color: theme.colors.text.ui.primary, @@ -12,9 +13,11 @@ const StyledLabel = styled(HeadlessLabel)(({ theme }) => ({ })); export default function Label({ required = true, children, ...props }) { + const t = useTranslations("Common"); + return ( - {children} {!required && (optional)} + {children} {!required && ({t("optional")})} ); } diff --git a/src/components/LegalAgreement/LegalAgreement.jsx b/src/components/LegalAgreement/LegalAgreement.jsx index 894925bb..2757978a 100644 --- a/src/components/LegalAgreement/LegalAgreement.jsx +++ b/src/components/LegalAgreement/LegalAgreement.jsx @@ -2,6 +2,7 @@ import { siteConfig } from "@/config/site"; import CheckboxRow from "@/components/CheckboxRow"; import StrongLink from "@/components/StrongLink"; +import { useTranslations } from "next-intl"; /** * @param {object} props @@ -10,25 +11,30 @@ import StrongLink from "@/components/StrongLink"; * @param {boolean} [props.disabled] */ function LegalAgreement({ defaultChecked, required, disabled = false }) { + const t = useTranslations("Legal"); + return ( - I have read and agree to the Peels{" "} - {/* Wrap links in spans as an alterntive to passive={true} on the label. This allows the rest of the label text to still act as a trigger on the checkbox. */} - e.stopPropagation()}> - - terms of use - - {" "} - and{" "} - e.stopPropagation()}> - - privacy policy - - + {t.rich("agreement", { + terms: (chunks) => ( + e.stopPropagation()}> + + {chunks} + + + ), + privacy: (chunks) => ( + e.stopPropagation()}> + + {chunks} + + + ), + })} ); } diff --git a/src/components/LegalFooter/LegalFooter.jsx b/src/components/LegalFooter/LegalFooter.jsx index d9673ac7..a17220f6 100644 --- a/src/components/LegalFooter/LegalFooter.jsx +++ b/src/components/LegalFooter/LegalFooter.jsx @@ -2,10 +2,13 @@ import { siteConfig } from "@/config/site"; import Link from "next/link"; import PeelsLogo from "@/components/PeelsLogo"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const currentYear = new Date().getFullYear(); export default function LegalFooter() { + const t = useTranslations(); + return ( @@ -14,13 +17,13 @@ export default function LegalFooter() {

- About - Support - Newsletter + {t("App.about")} + {t("Support.title")} + {t("Newsletter.title")} {/* Colophon */} - Terms - Privacy - Contact + {t("Legal.terms")} + {t("Legal.privacy")} + {t("Contact.title")}
); diff --git a/src/components/ListingCta/ListingCta.jsx b/src/components/ListingCta/ListingCta.jsx index 63a89773..f25a3102 100644 --- a/src/components/ListingCta/ListingCta.jsx +++ b/src/components/ListingCta/ListingCta.jsx @@ -4,6 +4,7 @@ import EncodedEmailLink from "@/components/EncodedEmailLink"; import Button from "@/components/Button"; import PeelsLogo from "@/components/PeelsLogo"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const StyledListingCta = styled("aside")(({ theme }) => ({ backgroundColor: theme.colors.background.slight, @@ -34,6 +35,8 @@ const Text = styled("div")(({ theme }) => ({ })); function ListingCta({ viewer, slug, visibility = true, isStub = false }) { + const t = useTranslations(); + if (viewer === "owner") { return ( @@ -42,13 +45,13 @@ function ListingCta({ viewer, slug, visibility = true, isStub = false }) { width="full" href={`/profile/listings/${slug}`} > - Edit listing + {t("Listings.edit.title")}

- This is your own listing{isStub && ", marked as a stub"}.{" "} - {visibility - ? "Lookin’ good!" - : "You’ve hidden it from the map, so only you can see this right now."} + {t("Listings.read.ownerNote", { + stub: isStub ? "true" : "false", + visibility: visibility ? "true" : "false", + })}

); @@ -58,16 +61,15 @@ function ListingCta({ viewer, slug, visibility = true, isStub = false }) { +

{t("Listings.read.stubNote")}

- This is a stub created by the Peels team. Double-check the listing - information before visiting. -

-

- Are you the owner?{" "} - - Reach out - {" "} - to claim this listing or to request changes. + {t.rich("Listings.read.stubClaim", { + link: (chunks) => ( + + {chunks} + + ), + })}

@@ -81,10 +83,12 @@ function ListingCta({ viewer, slug, visibility = true, isStub = false }) { width="full" href={`/sign-in?redirect_to=/listings/${slug}`} > - Sign in to contact + {t("Actions.signInToContact")}

- First time here? Sign up + {t.rich("Listings.read.firstTime", { + link: (chunks) => {chunks}, + })}

); diff --git a/src/components/ListingHeader/ListingHeader.jsx b/src/components/ListingHeader/ListingHeader.jsx index 064f6936..4458567c 100644 --- a/src/components/ListingHeader/ListingHeader.jsx +++ b/src/components/ListingHeader/ListingHeader.jsx @@ -4,6 +4,7 @@ import Avatar from "@/components/Avatar"; import Lozenge from "@/components/Lozenge"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const listingHeaderStyles = { display: "flex", @@ -96,6 +97,7 @@ const StyledLozenge = styled(Lozenge)(({ theme }) => ({ })); function ListingHeader({ presentation, listing, listingName, user }) { + const t = useTranslations(); const avatarProps = getListingAvatar(listing, user); return ( @@ -105,7 +107,7 @@ function ListingHeader({ presentation, listing, listingName, user }) { src={avatarProps?.isDemo ? avatarProps.path : undefined} bucket={!avatarProps?.isDemo ? avatarProps?.bucket : undefined} filename={!avatarProps?.isDemo ? avatarProps?.filename : undefined} - alt={avatarProps?.alt || "The avatar for this listing"} + alt={avatarProps?.alt || t("Listings.read.avatarAlt")} size="large" listing={listing} /> @@ -117,31 +119,31 @@ function ListingHeader({ presentation, listing, listingName, user }) { {listing?.type === "residential" && (

{listing?.area_name ? ( - <>Resident of {listing?.area_name} + <>{t("Listings.read.residentOf", { area: listing.area_name })} ) : ( - "Local resident" + t("Listings.read.localResident") )}

)} {listing?.type === "community" && (

{listing?.area_name ? ( - <>Community in {listing?.area_name} + <>{t("Listings.read.communityIn", { area: listing.area_name })} ) : ( - "Local community" + t("Listings.read.localCommunity") )}

)} {listing?.type === "business" && (

{listing?.area_name ? ( - <>Business in {listing?.area_name} + <>{t("Listings.read.businessIn", { area: listing.area_name })} ) : ( - "Local business" + t("Listings.read.localBusiness") )}

)} - {listing?.is_stub && Stub} + {listing?.is_stub && {t("Common.stub")}} ); diff --git a/src/components/ListingPhotosManager/ListingPhotosManager.tsx b/src/components/ListingPhotosManager/ListingPhotosManager.tsx index 689ec638..f0c88136 100644 --- a/src/components/ListingPhotosManager/ListingPhotosManager.tsx +++ b/src/components/ListingPhotosManager/ListingPhotosManager.tsx @@ -15,6 +15,7 @@ import { } from "@hello-pangea/dnd"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const DropzoneContents = styled("div")({ display: "flex", @@ -83,8 +84,6 @@ const MAX_PHOTOS = 5; const MAX_MB = 10; const MAX_FILE_SIZE = MAX_MB * 1024 * 1024; // 10MB in bytes const MAX_DIMENSION = 2048; // Reasonable max dimension for listing photos -const overSizedFileAlertSingular = `Your photo is too large. The maximum file size is ${MAX_MB}MB.`; -const overSizedFileAlertPlural = `One or more of your photos are too large. The maximum file size is ${MAX_MB}MB per photo.`; const RemoteImageComponent = RemoteImage as React.ComponentType; const uploadListingPhotoAction = uploadListingPhoto as ( @@ -109,6 +108,7 @@ function ListingPhotosManager({ onPhotosChange, isNewListing = false, }: ListingPhotosManagerProps) { + const t = useTranslations(); const [photos, setPhotos] = useState(initialPhotos); const photosRef = useRef(initialPhotos); const [isUploading, setIsUploading] = useState(false); @@ -147,7 +147,7 @@ function ListingPhotosManager({ // Reuse existing photo handling logic if (files.length + photosRef.current.length > MAX_PHOTOS) { - alert(`You can only upload up to ${MAX_PHOTOS} photos`); + alert(t("Listings.photos.tooMany", { max: MAX_PHOTOS })); return; } @@ -156,8 +156,8 @@ function ListingPhotosManager({ if (overSizedFiles.length > 0) { alert( overSizedFiles.length === 1 - ? overSizedFileAlertSingular - : overSizedFileAlertPlural + ? t("Listings.photos.tooLargeOne", { max: MAX_MB }) + : t("Listings.photos.tooLargeMany", { max: MAX_MB }) ); return; } @@ -196,13 +196,13 @@ function ListingPhotosManager({ ) { alert( files.length === 1 - ? overSizedFileAlertSingular - : overSizedFileAlertPlural + ? t("Listings.photos.tooLargeOne", { max: MAX_MB }) + : t("Listings.photos.tooLargeMany", { max: MAX_MB }) ); } else if (uploadError?.message?.includes("max_photos")) { - alert(`You can only upload up to ${MAX_PHOTOS} photos`); + alert(t("Listings.photos.tooMany", { max: MAX_PHOTOS })); } else { - alert("There was an error uploading your photos. Please try again."); + alert(t("Errors.photoUploadFailed")); } } finally { setIsUploading(false); @@ -242,7 +242,7 @@ function ListingPhotosManager({ ...currentPhotos.slice(restoreIndex), ]; }); - alert("Failed to delete photo. Please try again."); + alert(t("Errors.failedDeletePhoto")); } finally { setDeletingPhoto(null); } @@ -300,7 +300,9 @@ function ListingPhotosManager({ @@ -308,11 +310,11 @@ function ListingPhotosManager({ variant="danger" size="small" loading={deletingPhoto === filename} - loadingText="Deleting..." + loadingText={t("Status.deleting")} disabled={isMutatingPhotos} onClick={() => handlePhotoDelete(filename)} > - Delete + {t("Actions.delete")} )} @@ -327,7 +329,7 @@ function ListingPhotosManager({ {isDragActive && ( -

Drop photos here

+

{t("Listings.photos.dropHere")}

)} @@ -348,10 +350,12 @@ function ListingPhotosManager({ variant="secondary" size="small" loading={isUploading} - loadingText="Uploading..." + loadingText={t("Status.uploading")} disabled={isMutatingPhotos || photos.length >= MAX_PHOTOS} > - {photos.length > 0 ? "Add more photos" : "Add photos"} + {photos.length > 0 + ? t("Actions.addMorePhotos") + : t("Actions.addPhotos")} diff --git a/src/components/ListingRead/ListingRead.jsx b/src/components/ListingRead/ListingRead.jsx index dc24cc44..c6657349 100644 --- a/src/components/ListingRead/ListingRead.jsx +++ b/src/components/ListingRead/ListingRead.jsx @@ -15,6 +15,7 @@ import Button from "@/components/Button"; import ListingChatDrawer from "@/components/ListingChatDrawer"; import StrongLink from "@/components/StrongLink"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; // Memoize the Listing component const ListingRead = memo(function Listing({ @@ -24,6 +25,7 @@ const ListingRead = memo(function Listing({ isChatDrawerOpen, setIsChatDrawerOpen, }) { + const t = useTranslations(); const router = presentation !== "demo" ? useRouter() : null; const [existingThread, setExistingThread] = useState(null); @@ -94,10 +96,11 @@ const ListingRead = memo(function Listing({ {presentation === "demo" ? ( ) : ( @@ -116,7 +119,9 @@ const ListingRead = memo(function Listing({ {listing?.description && (

- {listing.type === "business" ? "Donation details" : "About"} + {listing.type === "business" + ? t("Listings.read.donationDetails") + : t("Listings.read.about")}

@@ -124,7 +129,7 @@ const ListingRead = memo(function Listing({ {listing?.accepted_items?.length > 0 && ( -

What’s accepted

+

{t("Listings.read.accepted")}

0 && ( -

What’s not

+

{t("Listings.read.rejected")}

{presentation !== "drawer" && ( -

Location

+

{t("Listings.read.location")}

{listing.type === "residential" ? (

- {listingDisplayName} is a resident of{" "} - {listing.area_name ? listing.area_name : "this area"}. Ask - them for their exact location when you arrange a food scrap - drop-off. + {t("Listings.read.residentialLocation", { + name: listingDisplayName, + area: listing.area_name + ? listing.area_name + : t("Listings.read.thisArea"), + })}

) : (

- {listingDisplayName} is{" "} - {listing.type === "business" - ? "a business" - : listing.type === "community" - ? "a community spot" - : ""}{" "} - located in {listing.area_name}. + {t("Listings.read.nonResidentialLocation", { + name: listingDisplayName, + type: + listing.type === "business" + ? t("Listings.read.businessType") + : listing.type === "community" + ? t("Listings.read.communityType") + : "", + area: listing.area_name, + })}

)} @@ -199,7 +209,7 @@ const ListingRead = memo(function Listing({ size="small" href={`/map?listing=${listing.slug}`} > - See nearby listings + {t("Actions.seeNearbyListings")} {listing.type !== "residential" && ( <> @@ -211,7 +221,7 @@ const ListingRead = memo(function Listing({ )}`} target="_blank" > - Open in Apple Maps + {t("Actions.openInAppleMaps")} )} @@ -235,11 +245,14 @@ const ListingRead = memo(function Listing({ !user && listing.type === "residential" ? undefined : "visible" } > -

Photos

+

{t("Common.photos")}

{!user && listing.type === "residential" ? (

- Sign in to see this - host’s photos. + {t.rich("Listings.read.signInForPhotos", { + link: (chunks) => ( + {chunks} + ), + })}

) : ( <> @@ -254,7 +267,7 @@ const ListingRead = memo(function Listing({ {listing.links?.length > 0 && ( -

Links

+

{t("Common.links")}

)} @@ -267,7 +280,7 @@ const ListingRead = memo(function Listing({ width="contained" href={`/listings/${listing.slug}`} > - View full listing + {t("Actions.viewFullListing")}
)} diff --git a/src/components/ListingWrite/ListingWrite.tsx b/src/components/ListingWrite/ListingWrite.tsx index 5a741dc1..a4c70c09 100644 --- a/src/components/ListingWrite/ListingWrite.tsx +++ b/src/components/ListingWrite/ListingWrite.tsx @@ -30,6 +30,7 @@ import Fieldset from "@/components/Fieldset"; import Lozenge from "@/components/Lozenge"; import FormMessage from "@/components/FormMessage"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const DESCRIPTION_MAX_CHARACTERS = 640; @@ -79,6 +80,7 @@ export default function ListingWrite({ user, profile, }: ListingWriteProps) { + const t = useTranslations(); const { type } = useParams<{ type?: string }>(); const router = useRouter(); @@ -165,7 +167,7 @@ export default function ListingWrite({ } } catch (error) { console.error("Error deleting listing:", error); - setFeedbackMessage("Hmm, something’s not right. Mind trying again?"); + setFeedbackMessage(t("Errors.generic")); } } @@ -197,7 +199,7 @@ export default function ListingWrite({ // For business/community listings, validate the name field const validation = validateName(name); if (!validation.isValid) { - nextErrors.name = `You can't have an empty ${listingType} name.`; + nextErrors.name = t("Errors.emptyListingName", { type: listingType }); } else { validatedName = validation.value ?? ""; // Store the trimmed value } @@ -206,7 +208,7 @@ export default function ListingWrite({ const selectedCoordinates = coordinates; if (!selectedCoordinates) { - nextErrors.location = "Please select a location."; + nextErrors.location = t("Errors.missingLocation"); } if (Object.keys(nextErrors).length > 0) { @@ -247,7 +249,7 @@ export default function ListingWrite({ } = await supabase.auth.getUser(); if (!activeUser) { - setGlobalError("Please sign in and then try again."); + setGlobalError(t("Errors.generic")); return; } @@ -287,7 +289,7 @@ export default function ListingWrite({ } if (!data?.slug) { - setGlobalError("Something went wrong. Please try again later."); + setGlobalError(t("Errors.genericLater")); return; } @@ -298,7 +300,7 @@ export default function ListingWrite({ ); } catch (error) { console.error("Error in handleSubmit:", error); - setGlobalError("An unexpected error occurred. Please try again later."); + setGlobalError(t("Errors.unexpected")); } finally { if (shouldResetSubmitting) { setIsSubmitting(false); @@ -342,7 +344,9 @@ export default function ListingWrite({ return ( <> - {initialListing && initialListing.is_stub && Stub} + {initialListing && initialListing.is_stub && ( + {t("Common.stub")} + )}
{listingType === "residential" ? ( -

Basics

+

{t("Listings.form.basics")}

{listingType === "residential" ? ( - + - {errors.name || "Use your first name or a nickname."} + {errors.name || t("Profile.account.firstNameHint")} ) : ( - + ) => setName(event.target.value) @@ -415,10 +426,10 @@ export default function ListingWrite({ initialListing ? areaName : listingType === "business" - ? "Your business’ address" + ? t("Listings.form.businessAddress") : listingType === "community" - ? "Your community’s address" - : "Your street or neighbourhood" + ? t("Listings.form.communityAddress") + : t("Listings.form.residentialAddress") } coordinates={coordinates} setCoordinates={setCoordinates} @@ -431,28 +442,29 @@ export default function ListingWrite({ {listingType === "business" ? ( - + ) => setDescription(event.target.value) } /> - What kind of scraps you have to give away and the collection - details. Save any links for the dedicated section, below. + {t("Listings.form.donationDetailsHint")} ) : ( ) => setDescription(event.target.value) @@ -468,11 +482,8 @@ export default function ListingWrite({ /> {listingType === "community" - ? "Opening hours and composting facilities." - : "Your composting set up and general availability."}{" "} - Save the scraps you accept{" "} - {listingType !== "residential" && "and any links"} for the - dedicated section{listingType !== "residential" && "s"}, below. + ? t("Listings.form.communityDescriptionHint") + : t("Listings.form.residentialDescriptionHint")} )} @@ -481,19 +492,18 @@ export default function ListingWrite({ {(listingType === "residential" || listingType === "community") && ( -

Composting details

-

- Be specific so people know exactly what should be avoided. Enter - items separately so it’s easier to read. -

+

{t("Listings.form.compostingDetails")}

+

{t("Listings.form.compostingDetailsHint")}

-

Media

+

{t("Listings.form.media")}

- Optionally show{" "} - {listingType === "residential" - ? "a bit more about your listing" - : listingType === "community" - ? "a bit more about your community project" - : "off your business"}{" "} - to Peels members. + {t("Listings.form.mediaHint", { + subject: + listingType === "residential" + ? t("Listings.form.mediaResidential") + : listingType === "community" + ? t("Listings.form.mediaCommunity") + : t("Listings.form.mediaBusiness"), + })}

-

Visibility

-

- Need a break from Peels? Temporarily hide this listing from the - map. -

+

{t("Listings.form.visibility")}

+

{t("Listings.form.visibilityHint")}

- + - - + +
@@ -588,12 +600,12 @@ export default function ListingWrite({ {profile?.is_admin && ( -

Admin

-

Admin-only controls for this listing.

+

{t("Common.admin")}

+

{t("Listings.form.adminHint")}

- + - + {isStub - ? "This listing will not contain your contact information. Others can claim it as their own and take it over." - : "This listing will be presented just like any other."} + ? t("Listings.form.stubActiveHint") + : t("Listings.form.stubInactiveHint")}
@@ -632,9 +642,9 @@ export default function ListingWrite({ message={{ error: globalError || - `Please fix the above error${ - Object.keys(errors).length > 1 ? "s" : "" - } and then try again.`, + t("Errors.validationSummary", { + count: Object.keys(errors).length, + }), }} /> )} @@ -642,9 +652,9 @@ export default function ListingWrite({ type="submit" variant="primary" loading={isSubmitting} - loadingText={initialListing ? "Saving..." : "Adding..."} + loadingText={initialListing ? t("Status.saving") : t("Status.adding")} > - {initialListing ? "Save changes" : "Add listing"} + {initialListing ? t("Actions.saveChanges") : t("Actions.addListing")} @@ -656,18 +666,18 @@ export default function ListingWrite({ width="contained" href={`/listings/${initialListing.slug}`} > - View listing + {t("Actions.viewListing")} - Are you sure you want to delete your listing? This is irreversible. + {t("Listings.delete.dialog")} )} diff --git a/src/components/LocationSelect/LocationSelect.jsx b/src/components/LocationSelect/LocationSelect.jsx index 713b1345..f9331cac 100644 --- a/src/components/LocationSelect/LocationSelect.jsx +++ b/src/components/LocationSelect/LocationSelect.jsx @@ -20,6 +20,7 @@ import Label from "@/components/Label"; import InputHint from "@/components/InputHint"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const StyledFieldset = styled(Fieldset)(({ theme }) => ({ display: "flex", @@ -127,12 +128,13 @@ export default function LocationSelect({ initialPlaceholderText, error, }) { + const t = useTranslations(); const mapRef = useRef(null); const inputRef = useRef(null); const [mapShown, setMapShown] = useState(coordinates ? true : false); const [placeholderText, setPlaceholderText] = useState( - initialPlaceholderText || "Your street name or nearby" + initialPlaceholderText || t("Listings.form.locationPlaceholder") ); useEffect(() => { @@ -174,8 +176,8 @@ export default function LocationSelect({ inputRef.current.blur(); // Close and blur the input if it's open console.log("handling drag start"); inputRef.current.setQuery(""); - setPlaceholderText("Custom location"); // Clear previous value - }, []); + setPlaceholderText(t("Listings.form.customLocation")); // Clear previous value + }, [t]); const handleDragEnd = useCallback( async (event) => { @@ -246,7 +248,7 @@ export default function LocationSelect({ return ( - + {/* TODO: Accessibility: label currently covers both select and geocoding control but not yet via htmlFor. Fix or make a separate visually hidden one for the geocoding control */} - - + + @@ -376,34 +376,34 @@ function ProfileAccountSettings({ - Update + {t("Actions.update")} ) : ( <> - +

{tempNewsletterPreference === true - ? "Subscribed" - : "Not subscribed"} + ? t("Common.subscribed") + : t("Common.notSubscribed")}

)} @@ -412,13 +412,13 @@ function ProfileAccountSettings({ <> - + {FIELD_CONFIGS.password.placeholder} diff --git a/src/components/ProfileActions/ProfileActions.tsx b/src/components/ProfileActions/ProfileActions.tsx index 40df3980..61e95d22 100644 --- a/src/components/ProfileActions/ProfileActions.tsx +++ b/src/components/ProfileActions/ProfileActions.tsx @@ -7,6 +7,7 @@ import EncodedEmailLink from "@/components/EncodedEmailLink"; import SubmitButton from "@/components/SubmitButton"; import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; const List = styled("ul")(({ theme }) => ({ display: "flex", @@ -44,67 +45,68 @@ type ProfileActionsProps = { }; export default function ProfileActions({ listings = [] }: ProfileActionsProps) { + const t = useTranslations(); + return ( -

Sign out

-

Goodbye for now!

+

{t("Profile.actions.signOutTitle")}

+

{t("Profile.actions.signOutDescription")}

- - Sign out + + {t("Actions.signOut")}
-

Export data

-

Get a copy of your Peels data

+

{t("Profile.actions.exportTitle")}

+

{t("Profile.actions.exportDescription")}

- We’re still working on this feature. In the meantime,{" "} - - reach out - {" "} - and ask us to export your data manually. + {t.rich("Profile.actions.exportDialog", { + link: (chunks) => ( + + {chunks} + + ), + })}
-

Delete account

+

{t("Profile.actions.deleteTitle")}

- Delete your account - {listings?.length > 0 && - `, ${listings.length > 1 ? "listings" : "listing"},`}{" "} - and all your data + {t("Profile.actions.deleteDescription", { + count: listings?.length ?? 0, + })}

0 - ? `Yes, delete my account and listing${listings.length > 1 ? "s" : ""}` - : "Yes, delete my account" - } - confirmLoadingText="Deleting..." + initialButtonText={t("Profile.actions.deleteTitle")} + dialogTitle={t("Profile.actions.deleteTitle")} + confirmButtonText={t("Profile.actions.deleteConfirm", { + count: listings?.length ?? 0, + })} + confirmLoadingText={t("Status.deleting")} action={deleteAccountAction} > - Are you sure you want to delete your account?{" "} - {listings?.length > 0 && ( - <> - Your listing{listings.length > 1 ? "s" : ""} will also be deleted. - - )} + {t("Profile.actions.deleteDialog", { + count: listings?.length ?? 0, + })}
diff --git a/src/components/ProfileHeader/ProfileHeader.jsx b/src/components/ProfileHeader/ProfileHeader.jsx index 16c0868e..52d693f8 100644 --- a/src/components/ProfileHeader/ProfileHeader.jsx +++ b/src/components/ProfileHeader/ProfileHeader.jsx @@ -1,5 +1,6 @@ import AvatarUploadManager from "@/components/AvatarUploadManager"; import { styled } from "@pigment-css/react"; +import { getTranslations } from "next-intl/server"; const Heading1 = styled("h1")({ fontSize: "2.25rem", @@ -17,6 +18,8 @@ const Heading2 = styled("h2")({ }); export default async function ProfileHeader({ profile, user }) { + const t = await getTranslations("Common"); + return ( <> {profile?.first_name && {profile?.first_name}} - {profile?.is_admin && Admin} + {profile?.is_admin && {t("admin")}} ); } diff --git a/src/components/ProfileListings/ProfileListings.jsx b/src/components/ProfileListings/ProfileListings.jsx index 2a23dc43..4232d9d3 100644 --- a/src/components/ProfileListings/ProfileListings.jsx +++ b/src/components/ProfileListings/ProfileListings.jsx @@ -112,7 +112,7 @@ const Text = styled("div")(({ theme }) => ({ })); export default function ProfileListings({ user, profile, listings }) { - const t = useTranslations("Profile"); + const t = useTranslations(); if (!listings) return null; return ( @@ -125,7 +125,7 @@ export default function ProfileListings({ user, profile, listings }) { size="small" profile={listing.type === "residential" ? profile : undefined} listing={listing.type !== "residential" ? listing : undefined} - alt={`Your avatar for this ${listing.type} listing`} + alt={t("Profile.listingCardAlt", { type: listing.type })} />

@@ -133,15 +133,14 @@ export default function ProfileListings({ user, profile, listings }) { ? profile.first_name : listing.name}

-

- {listing.type.charAt(0).toUpperCase() + listing.type.slice(1)}{" "} - listing -

+

{t("Profile.listingCardType", { type: listing.type })}

{!listing.visibility || listing.is_stub ? ( - {!listing.visibility && Hidden} - {listing.is_stub && Stub} + {!listing.visibility && ( + {t("Common.hidden")} + )} + {listing.is_stub && {t("Common.stub")}} ) : null} @@ -155,17 +154,15 @@ export default function ProfileListings({ user, profile, listings }) { -

{t("addListing")}

-

- Put yourself, your community spot, or your business on the map -

+

{t("Profile.addListing")}

+

{t("Profile.listingPrompt")}

) : ( -

Add another listing

+

{t("Profile.addAnotherListing")}

)} diff --git a/src/components/SignInForm/SignInForm.jsx b/src/components/SignInForm/SignInForm.jsx index ccd804e2..8ebdab5e 100644 --- a/src/components/SignInForm/SignInForm.jsx +++ b/src/components/SignInForm/SignInForm.jsx @@ -10,8 +10,10 @@ import Field from "@/components/Field"; import FieldHeader from "@/components/FieldHeader"; import StrongLink from "@/components/StrongLink"; import FormMessage from "@/components/FormMessage"; +import { useTranslations } from "next-intl"; function SignInForm({ searchParams }) { + const t = useTranslations(); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (event) => { @@ -44,7 +46,7 @@ function SignInForm({ searchParams }) { )} - + - - Forgot password? + + + {t("Auth.signIn.forgotPassword")} + @@ -74,9 +78,9 @@ function SignInForm({ searchParams }) { type="submit" variant="primary" loading={isSubmitting} - loadingText="Signing in..." + loadingText={t("Status.signingIn")} > - Sign in + {t("Actions.signIn")} ); diff --git a/src/components/SignUpForm/SignUpForm.tsx b/src/components/SignUpForm/SignUpForm.tsx index 671513d6..54b942b7 100644 --- a/src/components/SignUpForm/SignUpForm.tsx +++ b/src/components/SignUpForm/SignUpForm.tsx @@ -16,6 +16,7 @@ import { FIELD_CONFIGS, validateName } from "@/lib/formValidation"; import { getStoredAttributionParams } from "@/utils/attributionUtils"; import { isTurnstileEnabled } from "@/utils/utils"; import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface SignUpFormProps { @@ -29,16 +30,15 @@ interface SignUpFormProps { const BACKGROUND_TOKEN_TIMEOUT = 10000; const INTERACTIVE_TOKEN_TIMEOUT = 120000; -const EXPIRED_MESSAGE = "Verification expired. Please complete it again."; -const TIMEOUT_MESSAGE = - "Security check didn’t complete. Try disabling ad blockers and then try again. If it still fails, try a different browser or network."; -const UNSUPPORTED_MESSAGE = - "This browser can’t complete the security check. Please try a different browser or network."; export default function SignUpForm({ defaultValues = {}, error, }: SignUpFormProps) { + const t = useTranslations(); + const expiredMessage = t("Auth.turnstile.expired"); + const timeoutMessage = t("Auth.turnstile.timeout"); + const unsupportedMessage = t("Auth.turnstile.unsupported"); const [isSubmitting, setIsSubmitting] = useState(false); const [firstNameError, setFirstNameError] = useState(null); @@ -78,13 +78,13 @@ export default function SignUpForm({ ); const scheduleTokenTimeout = useCallback( - (timeout: number, errorMessage = TIMEOUT_MESSAGE) => { + (timeout: number, errorMessage = timeoutMessage) => { clearTokenTimeout(); tokenTimeoutRef.current = setTimeout(() => { rejectTokenPromise(errorMessage); }, timeout); }, - [clearTokenTimeout, rejectTokenPromise] + [clearTokenTimeout, rejectTokenPromise, timeoutMessage] ); // Promise-based token wait mechanism with timeout @@ -128,34 +128,34 @@ export default function SignUpForm({ setIsWaitingForToken(false); setIsTurnstileInteractive(false); - const errorMessage = `Security verification failed with error #${error}. Please try again or use a different browser.`; + const errorMessage = t("Auth.turnstile.failed", { code: error }); setCaptchaError(errorMessage); rejectTokenPromise(errorMessage); }, - [rejectTokenPromise] + [rejectTokenPromise, t] ); const handleTurnstileExpire = useCallback(() => { setIsWaitingForToken(false); setIsTurnstileInteractive(false); - setCaptchaError(EXPIRED_MESSAGE); - rejectTokenPromise(EXPIRED_MESSAGE); - }, [rejectTokenPromise]); + setCaptchaError(expiredMessage); + rejectTokenPromise(expiredMessage); + }, [expiredMessage, rejectTokenPromise]); const handleTurnstileTimeout = useCallback(() => { setIsWaitingForToken(false); setIsTurnstileInteractive(false); - setCaptchaError(TIMEOUT_MESSAGE); - rejectTokenPromise(TIMEOUT_MESSAGE); - }, [rejectTokenPromise]); + setCaptchaError(timeoutMessage); + rejectTokenPromise(timeoutMessage); + }, [rejectTokenPromise, timeoutMessage]); const handleTurnstileUnsupported = useCallback(() => { setIsWaitingForToken(false); setIsTurnstileInteractive(false); - setCaptchaError(UNSUPPORTED_MESSAGE); - rejectTokenPromise(UNSUPPORTED_MESSAGE); - }, [rejectTokenPromise]); + setCaptchaError(unsupportedMessage); + rejectTokenPromise(unsupportedMessage); + }, [rejectTokenPromise, unsupportedMessage]); const handleTurnstileBeforeInteractive = useCallback(() => { setCaptchaError(null); @@ -179,7 +179,7 @@ export default function SignUpForm({ const formData = new FormData(event.currentTarget); const validation = validateName(formData.get("first_name")?.toString()); if (!validation.isValid) { - setFirstNameError(validation.error ?? null); + setFirstNameError(t("Errors.emptyName")); return; } @@ -206,7 +206,7 @@ export default function SignUpForm({ } catch (error) { setIsWaitingForToken(false); setCaptchaError( - error instanceof Error ? error.message : TIMEOUT_MESSAGE + error instanceof Error ? error.message : timeoutMessage ); return; } @@ -267,7 +267,7 @@ export default function SignUpForm({ return (
- + - + - + @@ -305,7 +305,7 @@ export default function SignUpForm({ required={false} defaultChecked={defaultValues.newsletter_preference} > - Send me occasional email updates about Peels + {t("Auth.signUp.newsletterOptIn")} @@ -321,20 +321,18 @@ export default function SignUpForm({ {(error || hasFieldErrors) && ( - {error.endsWith(".") ? error : `${error}.`} If you think this - might be wrong, please{" "} - - email us - - . - - ) : hasFieldErrors ? ( - "Please fix the above error and then try again." - ) : ( - "Hmm, something went wrong. Please try again." - ), + error: error + ? t.rich("Auth.signUp.errorWithSupport", { + error: error.endsWith(".") ? error : `${error}.`, + link: (chunks) => ( + + {chunks} + + ), + }) + : hasFieldErrors + ? t("Errors.validationSummary", { count: 1 }) + : t("Errors.generic"), }} /> )} @@ -343,10 +341,12 @@ export default function SignUpForm({ type="submit" variant="primary" loading={isSubmitting || isWaitingForToken} - loadingText={isWaitingForToken ? "Verifying..." : "Signing up..."} + loadingText={ + isWaitingForToken ? t("Status.verifying") : t("Status.signingUp") + } disabled={isSubmitting || isWaitingForToken} > - Sign up + {t("Actions.signUp")} ); diff --git a/src/components/ThreadsList/ThreadsList.jsx b/src/components/ThreadsList/ThreadsList.jsx index 29c511f4..fe8ced21 100644 --- a/src/components/ThreadsList/ThreadsList.jsx +++ b/src/components/ThreadsList/ThreadsList.jsx @@ -7,6 +7,7 @@ import AvatarPair from "@/components/AvatarPair"; import { styled } from "@pigment-css/react"; import { useUnreadMessages } from "@/contexts/UnreadMessagesContext"; +import { useTranslations } from "next-intl"; const ThreadsSidebar = styled("div")(({ theme }) => ({ width: "100%", @@ -157,6 +158,7 @@ const ThreadPreviewText = styled("div")(({ theme }) => ({ })); function ThreadsList({ user, threads, currentThreadId }) { + const t = useTranslations("Chat"); const router = useRouter(); const { isThreadRead } = useUnreadMessages(); @@ -171,7 +173,7 @@ function ThreadsList({ user, threads, currentThreadId }) { return ( -

Chats

+

{t("threadsTitle")}

{threads?.length > 0 ? ( {threads.map((thread) => { @@ -239,7 +241,7 @@ function ThreadsList({ user, threads, currentThreadId }) { ) : ( -

No chats yet

+

{t("noChats")}

)}
From a735df8fcc5ffae2333442050ed523c1dcaa7ac9 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:12:51 +1000 Subject: [PATCH 2/7] address copilot i18n comments --- .github/workflows/validate-app.yml | 3 + messages/de.json | 12 +-- messages/en.json | 12 +-- messages/es.json | 12 +-- src/components/Label/{Label.jsx => Label.tsx} | 17 +++- .../{LegalFooter.jsx => LegalFooter.tsx} | 6 +- src/components/ListingWrite/ListingWrite.tsx | 22 +++-- ...{LocationSelect.jsx => LocationSelect.tsx} | 85 ++++++++++++------- .../{MultiInput.jsx => MultiInput.tsx} | 41 +++++++-- ...rofileListings.jsx => ProfileListings.tsx} | 55 ++++++------ src/components/SignUpForm/SignUpForm.tsx | 6 +- 11 files changed, 176 insertions(+), 95 deletions(-) rename src/components/Label/{Label.jsx => Label.tsx} (50%) rename src/components/LegalFooter/{LegalFooter.jsx => LegalFooter.tsx} (93%) rename src/components/LocationSelect/{LocationSelect.jsx => LocationSelect.tsx} (85%) rename src/components/MultiInput/{MultiInput.jsx => MultiInput.tsx} (69%) rename src/components/ProfileListings/{ProfileListings.jsx => ProfileListings.tsx} (87%) diff --git a/.github/workflows/validate-app.yml b/.github/workflows/validate-app.yml index 7e682a6b..88177116 100644 --- a/.github/workflows/validate-app.yml +++ b/.github/workflows/validate-app.yml @@ -27,3 +27,6 @@ jobs: - name: Run app checks run: npm run check + + - name: Build app + run: npm run build diff --git a/messages/de.json b/messages/de.json index 20c99de2..40a542e6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -74,7 +74,7 @@ }, "Errors": { "alreadyYourEmail": "Das ist bereits deine E-Mail-Adresse.", - "emptyListingName": "Der Name für {type} darf nicht leer sein.", + "emptyListingName": "{type, select, business {Der Name des Unternehmens darf nicht leer sein.} community {Der Name der Community darf nicht leer sein.} other {Der Name des Eintrags darf nicht leer sein.}}", "emptyName": "Der Name darf nicht leer sein.", "accountDeleted": "Dein Konto wurde gelöscht. Schade, dass du gehst.", "accountExists": "Ein Konto mit dieser E-Mail existiert bereits. Bitte melde dich stattdessen an.", @@ -164,8 +164,8 @@ "Profile": { "addListing": "Eintrag hinzufügen", "addAnotherListing": "Weiteren Eintrag hinzufügen", - "listingCardAlt": "Dein Avatar für diesen {type}-Eintrag", - "listingCardType": "{type}-Eintrag", + "listingCardAlt": "{type, select, residential {Dein Avatar für diesen Wohneintrag} community {Dein Avatar für diesen Community-Eintrag} business {Dein Avatar für diesen Unternehmenseintrag} other {Dein Avatar für diesen Eintrag}}", + "listingCardType": "{type, select, residential {Wohneintrag} community {Community-Eintrag} business {Unternehmenseintrag} other {Eintrag}}", "listingPrompt": "Setze dich, deinen Gemeinschaftsort oder dein Unternehmen auf die Karte", "sections": { "listings": "Einträge", @@ -260,13 +260,13 @@ "form": { "basics": "Grundlagen", "placeName": "Name des Ortes", - "placeNamePlaceholder": "Name deines {type}", + "placeNamePlaceholder": "{type, select, business {Name deines Unternehmens} community {Name deiner Community} other {Name des Ortes}}", "yourFirstName": "Dein Vorname", "location": "Standort", "selectCountry": "Land auswählen", "locationPlaceholder": "Deine Straße oder ein Ort in der Nähe", "customLocation": "Benutzerdefinierter Standort", - "locationHint": "Beginne zu tippen und wähle dann einen der vorgeschlagenen {kind} aus dem Menü.", + "locationHint": "{type, select, residential {Beginne zu tippen und wähle dann eine der vorgeschlagenen Optionen aus dem Menü.} other {Beginne zu tippen und wähle dann eine der vorgeschlagenen Adressen aus dem Menü.}}", "dragPinHint": "Ziehe die Markierung, um deinen Standort zu verfeinern{obscure, select, true { oder zu verschleiern} other {}}.", "businessAddress": "Adresse deines Unternehmens", "communityAddress": "Adresse deiner Gemeinschaft", @@ -275,7 +275,7 @@ "donationDetailsPlaceholder": "Details zu deiner Spende", "donationDetailsHint": "Welche Reste du abgeben möchtest und wie die Abholung funktioniert. Links gehören in den eigenen Abschnitt weiter unten.", "descriptionLabel": "Kurze Beschreibung oder Hinweise", - "descriptionPlaceholder": "Über deinen {type}", + "descriptionPlaceholder": "{type, select, residential {Über deinen Eintrag} community {Über deine Community} business {Über dein Unternehmen} other {Über deinen Eintrag}}", "communityDescriptionHint": "Öffnungszeiten und Kompostiermöglichkeiten. Hebe die akzeptierten Reste und Links für die eigenen Abschnitte weiter unten auf.", "residentialDescriptionHint": "Dein Kompost-Setup und deine allgemeine Verfügbarkeit. Hebe die akzeptierten Reste für den eigenen Abschnitt weiter unten auf.", "compostingDetails": "Kompostierungsdetails", diff --git a/messages/en.json b/messages/en.json index a22b546f..a3e8cfbc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -74,7 +74,7 @@ }, "Errors": { "alreadyYourEmail": "This is already your email address.", - "emptyListingName": "You can't have an empty {type} name.", + "emptyListingName": "{type, select, business {You can't have an empty business name.} community {You can't have an empty community name.} other {You can't have an empty listing name.}}", "emptyName": "You can’t have an empty name.", "accountDeleted": "Your account has been deleted. Sorry to see you go.", "accountExists": "An account with this email already exists. Please sign in instead.", @@ -164,8 +164,8 @@ "Profile": { "addListing": "Add a listing", "addAnotherListing": "Add another listing", - "listingCardAlt": "Your avatar for this {type} listing", - "listingCardType": "{type} listing", + "listingCardAlt": "{type, select, residential {Your avatar for this residential listing} community {Your avatar for this community listing} business {Your avatar for this business listing} other {Your avatar for this listing}}", + "listingCardType": "{type, select, residential {Residential listing} community {Community listing} business {Business listing} other {Listing}}", "listingPrompt": "Put yourself, your community spot, or your business on the map", "sections": { "listings": "Listings", @@ -260,13 +260,13 @@ "form": { "basics": "Basics", "placeName": "Place name", - "placeNamePlaceholder": "Your {type} name", + "placeNamePlaceholder": "{type, select, business {Your business’ name} community {Your community’s name} other {Your place name}}", "yourFirstName": "Your first name", "location": "Location", "selectCountry": "Select a country", "locationPlaceholder": "Your street name or nearby", "customLocation": "Custom location", - "locationHint": "Start typing, then select one of the suggested {kind} from the dropdown.", + "locationHint": "{type, select, residential {Start typing, then select one of the suggested options from the dropdown.} other {Start typing, then select one of the suggested addresses from the dropdown.}}", "dragPinHint": "Drag the pin to refine{obscure, select, true { or obscure} other {}} your location.", "businessAddress": "Your business’ address", "communityAddress": "Your community’s address", @@ -275,7 +275,7 @@ "donationDetailsPlaceholder": "Your donation details", "donationDetailsHint": "What kind of scraps you have to give away and the collection details. Save any links for the dedicated section, below.", "descriptionLabel": "Short description or instructions", - "descriptionPlaceholder": "About your {type}", + "descriptionPlaceholder": "{type, select, residential {About your listing} community {About your community} business {About your business} other {About your listing}}", "communityDescriptionHint": "Opening hours and composting facilities. Save the scraps you accept and any links for the dedicated sections, below.", "residentialDescriptionHint": "Your composting set up and general availability. Save the scraps you accept for the dedicated section, below.", "compostingDetails": "Composting details", diff --git a/messages/es.json b/messages/es.json index e0345752..0efbfa31 100644 --- a/messages/es.json +++ b/messages/es.json @@ -74,7 +74,7 @@ }, "Errors": { "alreadyYourEmail": "Esta ya es tu dirección de correo electrónico.", - "emptyListingName": "No puedes dejar vacío el nombre de {type}.", + "emptyListingName": "{type, select, business {No puedes dejar vacío el nombre del negocio.} community {No puedes dejar vacío el nombre de la comunidad.} other {No puedes dejar vacío el nombre del anuncio.}}", "emptyName": "No puedes dejar el nombre vacío.", "accountDeleted": "Tu cuenta ha sido eliminada. Sentimos verte partir.", "accountExists": "Ya existe una cuenta con este correo. Inicia sesión en su lugar.", @@ -164,8 +164,8 @@ "Profile": { "addListing": "Agregar un anuncio", "addAnotherListing": "Agregar otro anuncio", - "listingCardAlt": "Tu avatar para este anuncio de {type}", - "listingCardType": "Anuncio de {type}", + "listingCardAlt": "{type, select, residential {Tu avatar para este anuncio residencial} community {Tu avatar para este anuncio comunitario} business {Tu avatar para este anuncio de negocio} other {Tu avatar para este anuncio}}", + "listingCardType": "{type, select, residential {Anuncio residencial} community {Anuncio comunitario} business {Anuncio de negocio} other {Anuncio}}", "listingPrompt": "Ponte a ti, a tu espacio comunitario o a tu negocio en el mapa", "sections": { "listings": "Anuncios", @@ -260,13 +260,13 @@ "form": { "basics": "Datos básicos", "placeName": "Nombre del lugar", - "placeNamePlaceholder": "Nombre de tu {type}", + "placeNamePlaceholder": "{type, select, business {Nombre de tu negocio} community {Nombre de tu comunidad} other {Nombre del lugar}}", "yourFirstName": "Tu nombre", "location": "Ubicación", "selectCountry": "Selecciona un país", "locationPlaceholder": "Tu calle o un lugar cercano", "customLocation": "Ubicación personalizada", - "locationHint": "Empieza a escribir y selecciona una de las {kind} sugeridas en el menú.", + "locationHint": "{type, select, residential {Empieza a escribir y selecciona una de las opciones sugeridas en el menú.} other {Empieza a escribir y selecciona una de las direcciones sugeridas en el menú.}}", "dragPinHint": "Arrastra el pin para ajustar{obscure, select, true { u ocultar} other {}} tu ubicación.", "businessAddress": "Dirección de tu negocio", "communityAddress": "Dirección de tu comunidad", @@ -275,7 +275,7 @@ "donationDetailsPlaceholder": "Detalles de tu donación", "donationDetailsHint": "Qué tipo de restos tienes para donar y los detalles de recogida. Guarda los enlaces para la sección dedicada, más abajo.", "descriptionLabel": "Descripción breve o instrucciones", - "descriptionPlaceholder": "Acerca de tu {type}", + "descriptionPlaceholder": "{type, select, residential {Acerca de tu anuncio} community {Acerca de tu comunidad} business {Acerca de tu negocio} other {Acerca de tu anuncio}}", "communityDescriptionHint": "Horarios de apertura e instalaciones de compostaje. Guarda los restos que aceptas y cualquier enlace para las secciones dedicadas, más abajo.", "residentialDescriptionHint": "Tu sistema de compostaje y disponibilidad general. Guarda los restos que aceptas para la sección dedicada, más abajo.", "compostingDetails": "Detalles de compostaje", diff --git a/src/components/Label/Label.jsx b/src/components/Label/Label.tsx similarity index 50% rename from src/components/Label/Label.jsx rename to src/components/Label/Label.tsx index 03113124..c3eab648 100644 --- a/src/components/Label/Label.jsx +++ b/src/components/Label/Label.tsx @@ -1,6 +1,6 @@ import { Label as HeadlessLabel } from "@headlessui/react"; import { styled } from "@pigment-css/react"; -import { useTranslations } from "next-intl"; +import type { ComponentProps, ReactNode } from "react"; const StyledLabel = styled(HeadlessLabel)(({ theme }) => ({ color: theme.colors.text.ui.primary, @@ -12,12 +12,21 @@ const StyledLabel = styled(HeadlessLabel)(({ theme }) => ({ }, })); -export default function Label({ required = true, children, ...props }) { - const t = useTranslations("Common"); +type LabelProps = ComponentProps & { + required?: boolean; + optionalText?: string; + children: ReactNode; +}; +export default function Label({ + required = true, + optionalText = "", + children, + ...props +}: LabelProps) { return ( - {children} {!required && ({t("optional")})} + {children} {!required && optionalText && ({optionalText})} ); } diff --git a/src/components/LegalFooter/LegalFooter.jsx b/src/components/LegalFooter/LegalFooter.tsx similarity index 93% rename from src/components/LegalFooter/LegalFooter.jsx rename to src/components/LegalFooter/LegalFooter.tsx index a17220f6..5268d855 100644 --- a/src/components/LegalFooter/LegalFooter.jsx +++ b/src/components/LegalFooter/LegalFooter.tsx @@ -2,12 +2,12 @@ import { siteConfig } from "@/config/site"; import Link from "next/link"; import PeelsLogo from "@/components/PeelsLogo"; import { styled } from "@pigment-css/react"; -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; const currentYear = new Date().getFullYear(); -export default function LegalFooter() { - const t = useTranslations(); +export default async function LegalFooter() { + const t = await getTranslations(); return ( diff --git a/src/components/ListingWrite/ListingWrite.tsx b/src/components/ListingWrite/ListingWrite.tsx index a4c70c09..a0cb083d 100644 --- a/src/components/ListingWrite/ListingWrite.tsx +++ b/src/components/ListingWrite/ListingWrite.tsx @@ -401,10 +401,7 @@ export default function ListingWrite({ type="text" minLength={FIELD_CONFIGS.firstName.minLength} placeholder={t("Listings.form.placeNamePlaceholder", { - type: - listingType === "business" - ? "business’" - : `${listingType}’s`, + type: listingType, })} value={name} onChange={(event: React.ChangeEvent) => @@ -463,7 +460,11 @@ export default function ListingWrite({
) : ( - - {mapShown && ( + {mapShown && coordinates && ( {/*

Refine your pin location:

*/} @@ -346,11 +373,11 @@ export default function LocationSelect({ - + {t("Listings.form.dragPinHint", { obscure: listingType === "residential" ? "true" : "false", })} - +
)}
diff --git a/src/components/MultiInput/MultiInput.jsx b/src/components/MultiInput/MultiInput.tsx similarity index 69% rename from src/components/MultiInput/MultiInput.jsx rename to src/components/MultiInput/MultiInput.tsx index fb0ba367..22fb2f3b 100644 --- a/src/components/MultiInput/MultiInput.jsx +++ b/src/components/MultiInput/MultiInput.tsx @@ -1,5 +1,11 @@ "use client"; import { useId, useRef } from "react"; +import type { + ChangeEvent, + HTMLInputTypeAttribute, + KeyboardEvent, + ReactNode, +} from "react"; import Form from "@/components/Form"; import Fieldset from "@/components/Fieldset"; @@ -12,6 +18,22 @@ import Textarea from "@/components/Textarea"; import { styled } from "@pigment-css/react"; +type MultiInputProps = { + label: ReactNode; + placeholder?: string; + secondaryPlaceholder?: string; + items: string[]; + minRequired?: number; + handleItemChange: (index: number, value: string) => void; + onClick: () => void; + limit?: number; + type?: HTMLInputTypeAttribute; + pattern?: string; + addButtonText?: string; + addAnotherButtonText?: string; + optionalText?: string; +}; + function MultiInput({ label, placeholder = undefined, @@ -25,9 +47,10 @@ function MultiInput({ pattern = undefined, addButtonText = "Add", addAnotherButtonText = "Add another", -}) { + optionalText, +}: MultiInputProps) { const uniqueId = useId(); - const addButtonRef = useRef(null); + const addButtonRef = useRef(null); const handleAddItem = () => { onClick(); @@ -41,7 +64,10 @@ function MultiInput({ }, 0); }; - const handleInputKeyDown = (e, index) => { + const handleInputKeyDown = ( + e: KeyboardEvent, + index: number + ) => { if (e.key === "Enter") { e.preventDefault(); // Prevent form submission if (addButtonRef.current) { @@ -56,6 +82,7 @@ function MultiInput({ @@ -68,8 +95,12 @@ function MultiInput({ pattern={pattern ? pattern : undefined} placeholder={index === 0 ? placeholder : secondaryPlaceholder} value={item} - onChange={(e) => handleItemChange(index, e.target.value)} - onKeyDown={(e) => handleInputKeyDown(e, index)} + onChange={(e: ChangeEvent) => + handleItemChange(index, e.target.value) + } + onKeyDown={(e: KeyboardEvent) => + handleInputKeyDown(e, index) + } /> ))} {items.length < limit && ( diff --git a/src/components/ProfileListings/ProfileListings.jsx b/src/components/ProfileListings/ProfileListings.tsx similarity index 87% rename from src/components/ProfileListings/ProfileListings.jsx rename to src/components/ProfileListings/ProfileListings.tsx index 4232d9d3..5f3046fb 100644 --- a/src/components/ProfileListings/ProfileListings.jsx +++ b/src/components/ProfileListings/ProfileListings.tsx @@ -5,6 +5,8 @@ import Avatar from "@/components/Avatar"; import Lozenge from "@/components/Lozenge"; import { styled } from "@pigment-css/react"; +const AvatarComponent = Avatar as any; + const MAX_LISTINGS = 12; // TODO: Store this value on Supabase and use in the related RLS policy, so they are always in sync const ListingsList = styled("ul")(({ theme }) => ({ @@ -44,7 +46,7 @@ const NewListingAvatar = styled("div")(({ theme }) => ({ color: theme.colors.text.brand.quaternary, })); -const sharedLinkStyles = ({ theme }) => ({ +const sharedLinkStyles = ({ theme }: { theme: any }) => ({ padding: "0.75rem 1rem", // Visually match parent padding display: "flex", flexDirection: "row", @@ -73,7 +75,7 @@ const AddAnotherListingLink = styled(Link)(sharedLinkStyles, { const ExistingListingLink = styled(Link)(sharedLinkStyles, {}); -const Text = styled("div")(({ theme }) => ({ +const textStyles = ({ theme }: { theme: any }) => ({ display: "flex", flexDirection: "column", flex: 1, @@ -87,31 +89,30 @@ const Text = styled("div")(({ theme }) => ({ fontSize: "0.875rem", color: theme.colors.text.ui.quaternary, }, +}); + +const Text = styled("div")(textStyles); + +const SpecialText = styled("div")(({ theme }) => ({ + ...textStyles({ theme }), - variants: [ - { - props: { - special: false, - }, - style: { - "& p": { - lineHeight: "100%", - }, - }, - props: { - special: true, - }, - style: { - "& h3": { - color: theme.colors.text.brand.primary, - fontSize: "1.25rem", - }, - }, - }, - ], + "& h3": { + color: theme.colors.text.brand.primary, + fontSize: "1.25rem", + }, })); -export default function ProfileListings({ user, profile, listings }) { +type ProfileListingsProps = { + user: any; + profile: any; + listings?: any[] | null; +}; + +export default function ProfileListings({ + user, + profile, + listings, +}: ProfileListingsProps) { const t = useTranslations(); if (!listings) return null; @@ -121,7 +122,7 @@ export default function ProfileListings({ user, profile, listings }) { return (
  • - - +

    {t("Profile.addListing")}

    {t("Profile.listingPrompt")}

    -
    + ) : ( diff --git a/src/components/SignUpForm/SignUpForm.tsx b/src/components/SignUpForm/SignUpForm.tsx index 54b942b7..6121b8fa 100644 --- a/src/components/SignUpForm/SignUpForm.tsx +++ b/src/components/SignUpForm/SignUpForm.tsx @@ -52,7 +52,9 @@ export default function SignUpForm({ const tokenTimeoutRef = useRef | null>(null); const turnstileEnabled = isTurnstileEnabled(); - const hasFieldErrors = Boolean(firstNameError || captchaError); + const fieldErrorCount = + Number(Boolean(firstNameError)) + Number(Boolean(captchaError)); + const hasFieldErrors = fieldErrorCount > 0; const clearTokenTimeout = useCallback(() => { if (tokenTimeoutRef.current) { @@ -331,7 +333,7 @@ export default function SignUpForm({ ), }) : hasFieldErrors - ? t("Errors.validationSummary", { count: 1 }) + ? t("Errors.validationSummary", { count: fieldErrorCount }) : t("Errors.generic"), }} /> From 2a562e58efd3c1598624d9806bcb187c93dd30ac Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:20:11 +1000 Subject: [PATCH 3/7] add agent collaboration guidelines --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..cf4a445d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS.md + +These instructions apply to the whole repository. + +## Language + +- Use Australian English in PR descriptions, review replies, docs, and other prose. +- Use US English in code identifiers, filenames, script names, and API shapes. + +## Frontend + +- Prefer Server Components by default. Only add `"use client"` when the component needs browser-only APIs, state/effects, event handlers, or client-only libraries. +- When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change. Avoid broad conversion-only refactors. +- Keep shared presentational components server-safe where possible. For translated labels or suffixes, prefer passing translated text from the caller instead of adding `useTranslations` to a shared component. + +## Internationalisation + +- Put user-facing app UI copy in `next-intl` message files under `messages/`. +- Treat `messages/en.json` as the source catalogue. Keep Spanish (`es`) and German (`de`) complete whenever adding or changing message keys. +- Run `npm run i18n:check` after editing messages. Run `npm run check` before handing work back. +- Do not put dynamic user-generated content, internal enum values, table names, route constants, CSS strings, logs, or analytics identifiers into message files. +- For listing types and other enums, pass stable keys such as `business`, `community`, or `residential` and handle display grammar in translations, usually with ICU `select`. From da9fc18b55e007d7f418d6d63677a565398c493b Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:24:58 +1000 Subject: [PATCH 4/7] agents cleanup --- AGENTS.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf4a445d..1ccb759b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,12 @@ These instructions apply to the whole repository. ## Language -- Use Australian English in PR descriptions, review replies, docs, and other prose. -- Use US English in code identifiers, filenames, script names, and API shapes. +- Use Australian/British English over US English. ## Frontend - Prefer Server Components by default. Only add `"use client"` when the component needs browser-only APIs, state/effects, event handlers, or client-only libraries. -- When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change. Avoid broad conversion-only refactors. +- When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change. - Keep shared presentational components server-safe where possible. For translated labels or suffixes, prefer passing translated text from the caller instead of adding `useTranslations` to a shared component. ## Internationalisation From c7c3e5befe2b8b83b053fa5c6f6e5adfd439c480 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:33:01 +1000 Subject: [PATCH 5/7] preserve newsletter mdx list spacing --- AGENTS.md | 1 + .../beginning-to-look-like-compost.mdx | 50 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ccb759b..de566e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ These instructions apply to the whole repository. - Prefer Server Components by default. Only add `"use client"` when the component needs browser-only APIs, state/effects, event handlers, or client-only libraries. - When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change. - Keep shared presentational components server-safe where possible. For translated labels or suffixes, prefer passing translated text from the caller instead of adding `useTranslations` to a shared component. +- In MDX prose, if an inline component inside a Markdown list item is formatted onto multiple lines and changes rendered spacing, prefer a JSX `
      `/`
    • ` block with explicit `{" "}` spacing over disabling formatting for the whole file. ## Internationalisation diff --git a/src/content/newsletter/beginning-to-look-like-compost.mdx b/src/content/newsletter/beginning-to-look-like-compost.mdx index 1a807a4d..3c3a0625 100644 --- a/src/content/newsletter/beginning-to-look-like-compost.mdx +++ b/src/content/newsletter/beginning-to-look-like-compost.mdx @@ -32,27 +32,35 @@ It’s the end of the year, and Peels - How to Save Our Planet - - included Peels in its composting episode. -- - Responsible Cafes - - featured Peels in a community composting guide. -- - The Rot - - interviewed me about how Peels started. +
        +
      • + + How to Save Our Planet + {" "} + included Peels in its composting episode. +
      • +
      • + + Responsible Cafes + {" "} + featured Peels in a community composting guide. +
      • +
      • + + The Rot + {" "} + interviewed me about how Peels started. +
      • +
      You can see the full list in our promo kit. From 25a4a652acee08191e29925ea82508b45fc58a85 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:34:53 +1000 Subject: [PATCH 6/7] copilot --- messages/de.json | 2 +- src/components/LocationSelect/LocationSelect.tsx | 4 ---- src/components/MultiInput/MultiInput.tsx | 5 ----- src/components/PostageStamp/PostageStamp.jsx | 2 -- 4 files changed, 1 insertion(+), 12 deletions(-) diff --git a/messages/de.json b/messages/de.json index 40a542e6..31f71712 100644 --- a/messages/de.json +++ b/messages/de.json @@ -301,7 +301,7 @@ "mapVisibility": "Sichtbarkeit auf der Karte", "showOnMap": "Diesen Eintrag auf der Karte anzeigen", "hideFromMap": "Diesen Eintrag von der Karte ausblenden", - "adminHint": "Admin-only controls for this listing.", + "adminHint": "Nur für Admins sichtbare Steuerelemente für diesen Eintrag.", "stubSettings": "Stub-Einstellungen", "regularListing": "Dies ist ein regulärer Eintrag, der dir gehört", "stubListing": "Dieser Eintrag ist ein Stub, den andere beanspruchen können", diff --git a/src/components/LocationSelect/LocationSelect.tsx b/src/components/LocationSelect/LocationSelect.tsx index 9071a86a..e623593e 100644 --- a/src/components/LocationSelect/LocationSelect.tsx +++ b/src/components/LocationSelect/LocationSelect.tsx @@ -62,13 +62,9 @@ async function getAreaName( longitude: number, latitude: number ): Promise { - // const result = await maptilersdk.geocoding.reverse([6.249638, 46.402056]); - // console.log({ result }); - // config.apiKey = process.env.NEXT_PUBLIC_MAPTILER_API_KEY; const coordinates = await geocoding.reverse([longitude, latitude]); const features = coordinates.features as any[]; - console.log({ features }); if (!features || features.length === 0) { return ""; diff --git a/src/components/MultiInput/MultiInput.tsx b/src/components/MultiInput/MultiInput.tsx index 22fb2f3b..9da103d1 100644 --- a/src/components/MultiInput/MultiInput.tsx +++ b/src/components/MultiInput/MultiInput.tsx @@ -7,16 +7,11 @@ import type { ReactNode, } from "react"; -import Form from "@/components/Form"; import Fieldset from "@/components/Fieldset"; import Field from "@/components/Field"; import Label from "@/components/Label"; import Input from "@/components/Input"; -import SubmitButton from "@/components/SubmitButton"; import Button from "@/components/Button"; -import Textarea from "@/components/Textarea"; - -import { styled } from "@pigment-css/react"; type MultiInputProps = { label: ReactNode; diff --git a/src/components/PostageStamp/PostageStamp.jsx b/src/components/PostageStamp/PostageStamp.jsx index 901a2530..f6689039 100644 --- a/src/components/PostageStamp/PostageStamp.jsx +++ b/src/components/PostageStamp/PostageStamp.jsx @@ -11,8 +11,6 @@ export default function PostageStamp() { alt={t("stampAlt")} width={224} height={132} - aria-hidden="true" - role="presentation" /> ); } From 8e54154e3a2b51e0cabbfa11807914799a9e2cbf Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:40:48 +1000 Subject: [PATCH 7/7] fix newsletter mdx list spacing --- AGENTS.md | 2 +- .../beginning-to-look-like-compost.mdx | 33 +++---------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de566e56..b3cbd7e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ These instructions apply to the whole repository. - Prefer Server Components by default. Only add `"use client"` when the component needs browser-only APIs, state/effects, event handlers, or client-only libraries. - When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change. - Keep shared presentational components server-safe where possible. For translated labels or suffixes, prefer passing translated text from the caller instead of adding `useTranslations` to a shared component. -- In MDX prose, if an inline component inside a Markdown list item is formatted onto multiple lines and changes rendered spacing, prefer a JSX `
        `/`
      • ` block with explicit `{" "}` spacing over disabling formatting for the whole file. +- In MDX prose, if an inline component inside a Markdown list item is formatted onto multiple lines and changes rendered spacing, use a targeted `{/* prettier-ignore */}` before that list rather than disabling formatting for the whole file. ## Internationalisation diff --git a/src/content/newsletter/beginning-to-look-like-compost.mdx b/src/content/newsletter/beginning-to-look-like-compost.mdx index 3c3a0625..a733a6f4 100644 --- a/src/content/newsletter/beginning-to-look-like-compost.mdx +++ b/src/content/newsletter/beginning-to-look-like-compost.mdx @@ -32,35 +32,10 @@ It’s the end of the year, and Peels -
      • - - How to Save Our Planet - {" "} - included Peels in its composting episode. -
      • -
      • - - Responsible Cafes - {" "} - featured Peels in a community composting guide. -
      • -
      • - - The Rot - {" "} - interviewed me about how Peels started. -
      • -
      +{/* prettier-ignore */} +- How to Save Our Planet included Peels in its composting episode. +- Responsible Cafes featured Peels in a community composting guide. +- The Rot interviewed me about how Peels started. You can see the full list in our promo kit.