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({
-
- 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",
+ })}
- 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" && (
)}
@@ -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")}
+ )}
@@ -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 */}
@@ -343,8 +347,9 @@ export default function LocationSelect({
- Drag the pin to refine{" "}
- {listingType === "residential" && "or obscure"} your location.
+ {t("Listings.form.dragPinHint", {
+ obscure: listingType === "residential" ? "true" : "false",
+ })}
)}
diff --git a/src/components/MapPageClient/MapPageClient.jsx b/src/components/MapPageClient/MapPageClient.jsx
index 5bb140a5..0db8a6de 100644
--- a/src/components/MapPageClient/MapPageClient.jsx
+++ b/src/components/MapPageClient/MapPageClient.jsx
@@ -27,6 +27,7 @@ import {
getListingDisplayType,
} from "@/utils/listingUtils";
import { useDeviceContext } from "@/hooks/useDeviceContext";
+import { useTranslations } from "next-intl";
const sidebarWidth = "clamp(20rem, 30vw, 30rem)";
const pagePadding = "24px";
@@ -257,6 +258,7 @@ export default function MapPageClient({
initialListingSlug,
initialListing,
}) {
+ const t = useTranslations();
const mapRef = useRef(null);
const searchInputRef = useRef(null);
const drawerContentRef = useRef(null);
@@ -392,14 +394,17 @@ export default function MapPageClient({
.single();
if (error) {
- setSelectedListing({ error: true, message: "Listing not found" });
+ setSelectedListing({
+ error: true,
+ message: t("Listings.edit.notFound"),
+ });
return;
}
setSelectedListing(data);
} catch (err) {
console.warn("loadListingBySlug failed:", err);
- setSelectedListing({ error: true, message: "Listing not found" });
+ setSelectedListing({ error: true, message: t("Listings.edit.notFound") });
}
};
@@ -456,7 +461,10 @@ export default function MapPageClient({
.single();
if (error) {
- setSelectedListing({ error: true, message: "Listing not found" });
+ setSelectedListing({
+ error: true,
+ message: t("Listings.edit.notFound"),
+ });
setIsDrawerOpen(true);
setSnap(snapPoints[0]);
return;
@@ -476,7 +484,7 @@ export default function MapPageClient({
router.push(`/map?listing=${data.slug}`, { scroll: false });
} catch (err) {
console.warn("handleMarkerClick failed:", err);
- setSelectedListing({ error: true, message: "Listing not found" });
+ setSelectedListing({ error: true, message: t("Listings.edit.notFound") });
setIsDrawerOpen(true);
setSnap(snapPoints[0]);
}
@@ -682,9 +690,9 @@ export default function MapPageClient({
}}
>
- Nested chat drawer
+ {t("Map.drawerTitle")}
- Test description for aria.
+ {t("Map.drawerDescription")}
@@ -742,14 +750,11 @@ export default function MapPageClient({
{selectedListing?.error ? (
-
Coming up empty
-
- The listing you’re looking for doesn’t exist or has been
- removed. Sorry to disappoint.
-
+
{t("Map.emptyTitle")}
+
{t("Map.emptyBody")}
) : (
diff --git a/src/components/MapSearch/MapSearch.jsx b/src/components/MapSearch/MapSearch.jsx
index 8864e5aa..07bb53d5 100644
--- a/src/components/MapSearch/MapSearch.jsx
+++ b/src/components/MapSearch/MapSearch.jsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState, useRef } from "react";
import { GeocodingControl } from "@maptiler/geocoding-control/react";
import "@maptiler/geocoding-control/style.css"; // TODO REMOVE (TURN ON AND OFF TO PREVIEW STYLES)
+import { useTranslations } from "next-intl";
// TODO: Add a 'required' prop for forms that require a location
function MapSearch({
@@ -12,6 +13,8 @@ function MapSearch({
countryCode,
...props
}) {
+ const t = useTranslations("Map");
+
return (
{
onPick(event);
diff --git a/src/components/MapSidebar/MapSidebar.jsx b/src/components/MapSidebar/MapSidebar.jsx
index 38e08248..70f526cb 100644
--- a/src/components/MapSidebar/MapSidebar.jsx
+++ b/src/components/MapSidebar/MapSidebar.jsx
@@ -4,12 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { styled } from "@pigment-css/react";
import { facts } from "@/data/facts";
-
-const steps = [
- { title: "Find a host", description: "Select a marker on the map." },
- { title: "Contact", description: "Arrange a drop-off via chat." },
- { title: "Drop-off", description: "Meet your neighbours!" },
-];
+import { useTranslations } from "next-intl";
const sidebarWidth = "clamp(20rem, 30vw, 30rem);";
const StyledSidebar = styled("div")(({ theme }) => ({
@@ -119,7 +114,22 @@ const StepList = styled("ol")(({ theme }) => ({
}));
export default function MapSidebar({ user, covered }) {
+ const t = useTranslations();
const [randomFact, setRandomFact] = useState(null);
+ const steps = [
+ {
+ title: t("Map.steps.find.title"),
+ description: t("Map.steps.find.description"),
+ },
+ {
+ title: t("Map.steps.contact.title"),
+ description: t("Map.steps.contact.description"),
+ },
+ {
+ title: t("Map.steps.dropOff.title"),
+ description: t("Map.steps.dropOff.description"),
+ },
+ ];
useEffect(() => {
// Only generate a random fact if there is NO selected listing, not when one is opened
@@ -139,13 +149,13 @@ export default function MapSidebar({ user, covered }) {
// If user has sent >0 messages, show a fun composting fact
// Otherwise show the fundamentals (1, 2, 3) of Peels
-
Did you know?
+
{t("Map.didYouKnow")}
{randomFact.fact}
{randomFact.source && (
- Source
+ {t("Common.source")}
diff --git a/src/components/NewsletterAside/NewsletterAside.jsx b/src/components/NewsletterAside/NewsletterAside.jsx
index ed50bd1f..02c5de0a 100644
--- a/src/components/NewsletterAside/NewsletterAside.jsx
+++ b/src/components/NewsletterAside/NewsletterAside.jsx
@@ -3,34 +3,35 @@
import { useNewsletterStatus } from "@/hooks/useNewsletterStatus";
import StrongLink from "@/components/StrongLink";
import { styled } from "@pigment-css/react";
+import { useTranslations } from "next-intl";
// Replaces EmailAside for the web version of newsletter issues
// Used as an aside within a newsletter, explaining the context of the issue
// and providing information on how to opt-in (whether signed up already or not)
// See also NewsletterCallout which does a similar job, albeit outside of the newsletter bounds
export default function NewsletterAside() {
+ const t = useTranslations("Newsletter.aside");
const status = useNewsletterStatus();
return (
);
diff --git a/src/components/NewsletterCallout/NewsletterCallout.jsx b/src/components/NewsletterCallout/NewsletterCallout.jsx
index 0789cfcb..ea0b5d03 100644
--- a/src/components/NewsletterCallout/NewsletterCallout.jsx
+++ b/src/components/NewsletterCallout/NewsletterCallout.jsx
@@ -3,44 +3,24 @@ import { useNewsletterStatus } from "@/hooks/useNewsletterStatus";
import Button from "@/components/Button";
import PostageStamp from "@/components/PostageStamp";
import { styled } from "@pigment-css/react";
-
-const userConfig = {
- alreadySubscribed: {
- title: "You’re already subscribed",
- description:
- "You should see the next issue appear in your email inbox. Feel free to share this page with a friend in the meantime!",
- button: {
- text: "Edit newsletter preference",
- href: "/profile",
- },
- },
- notYetSubscribed: {
- title: "You’re not subscribed",
- description: "Change your newsletter preference on your Profile page.",
- button: {
- text: "Edit newsletter preference",
- href: "/profile",
- },
- },
-};
+import { useTranslations } from "next-intl";
function UnauthenticatedCallout() {
+ const t = useTranslations();
+
return (
-
Join Peels to get the newsletter
-
- You need to be a member of Peels to get the newsletter via email.
- Signing up is free and only takes a few seconds.
-
+
{t("Newsletter.callout.guestTitle")}
+
{t("Newsletter.callout.guestBody")}
@@ -51,6 +31,7 @@ function UnauthenticatedCallout() {
// and providing information on how to opt-in (whether signed up already or not)
// See also NewsletterAside which does a similar job, albeit inside the newsletter bounds
export default function NewsletterCallout() {
+ const t = useTranslations();
const status = useNewsletterStatus();
if (status.isLoading || status.error || !status.isAuthenticated) {
@@ -63,20 +44,20 @@ export default function NewsletterCallout() {
);
diff --git a/src/components/NewsletterIssuesList/NewsletterIssuesList.tsx b/src/components/NewsletterIssuesList/NewsletterIssuesList.tsx
index dd36f2e7..a0b8fccb 100644
--- a/src/components/NewsletterIssuesList/NewsletterIssuesList.tsx
+++ b/src/components/NewsletterIssuesList/NewsletterIssuesList.tsx
@@ -3,12 +3,14 @@ import NewsletterIssueTile from "@/components/NewsletterIssueTile";
import StyledList from "@/components/StyledList";
import PastIssuesList from "@/components/PastIssuesList";
import EmptyIssueSlot from "@/components/EmptyIssueSlot";
+import { getTranslations } from "next-intl/server";
export default async function NewsletterIssuesList({
showPastIssues = true,
}: {
showPastIssues?: boolean;
}) {
+ const t = await getTranslations("Newsletter");
const newsletterIssues = await getAllNewsletterIssues();
// Only show an empty issue slot if the number of issues is even
@@ -20,7 +22,7 @@ export default async function NewsletterIssuesList({
<>
-
>
)}
@@ -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 })}
/>
)}
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({
) : (
-
-
+
) : (
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.