From 531114846792ba843e0f5ac6fab7029c184e41d0 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 12 Mar 2026 12:13:26 +0100 Subject: [PATCH 1/8] Add i18n support with next-intl for EN, PL, and DE locales Integrate next-intl for full internationalization across the storefront, checkout, account, and cart. Translate all hardcoded English strings including filters, sort options, price buckets, order statuses, address forms, and cart drawer. Use locale-aware currency formatting and date formatting. Fix locale switching via hard navigation in CountrySwitcher. DRY up store name/description with centralized getStoreName/getStoreDescription. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 382 ++++++++ messages/en.json | 382 ++++++++ messages/pl.json | 382 ++++++++ next.config.ts | 9 +- package-lock.json | 888 +++++++++++++++--- package.json | 4 +- scripts/check-locale-parity.ts | 79 ++ .../(checkout)/checkout/[id]/page.tsx | 39 +- .../[country]/[locale]/(checkout)/layout.tsx | 16 +- .../(checkout)/order-placed/[id]/page.tsx | 46 +- .../(storefront)/account/addresses/page.tsx | 27 +- .../account/credit-cards/page.tsx | 31 +- .../(storefront)/account/gift-cards/page.tsx | 80 +- .../[locale]/(storefront)/account/layout.tsx | 35 +- .../(storefront)/account/orders/[id]/page.tsx | 54 +- .../(storefront)/account/orders/page.tsx | 73 +- .../[locale]/(storefront)/account/page.tsx | 44 +- .../(storefront)/account/profile/page.tsx | 25 +- .../(storefront)/account/register/page.tsx | 35 +- .../CategoryProductsContent.tsx | 4 +- .../(storefront)/c/[...permalink]/page.tsx | 4 +- .../[locale]/(storefront)/cart/page.tsx | 43 +- .../[country]/[locale]/(storefront)/page.tsx | 17 +- .../(storefront)/products/ProductsContent.tsx | 17 +- .../products/[slug]/ProductDetails.tsx | 24 +- src/app/layout.tsx | 30 +- src/components/cart/CartDrawer.tsx | 32 +- src/components/checkout/AddressEditModal.tsx | 11 +- src/components/checkout/AddressFormFields.tsx | 45 +- src/components/checkout/AddressSelector.tsx | 11 +- src/components/checkout/AddressStep.tsx | 26 +- src/components/checkout/CouponCode.tsx | 17 +- src/components/checkout/DeliveryStep.tsx | 29 +- src/components/checkout/OrderSummary.tsx | 16 +- src/components/checkout/PaymentStep.tsx | 42 +- src/components/layout/CountrySwitcher.tsx | 13 +- src/components/layout/Footer.tsx | 29 +- src/components/layout/Header.tsx | 6 +- src/components/navigation/Breadcrumbs.tsx | 6 +- src/components/products/MediaGallery.tsx | 10 +- src/components/products/ProductCard.tsx | 6 +- src/components/products/ProductCarousel.tsx | 6 +- src/components/products/ProductGrid.tsx | 6 +- .../products/ProductListingLayout.tsx | 14 +- src/components/products/VariantPicker.tsx | 4 +- .../products/__tests__/ProductCard.test.tsx | 10 +- .../filters/AvailabilityDropdownContent.tsx | 15 +- .../products/filters/FilterChips.tsx | 12 +- .../products/filters/MobileFilterDrawer.tsx | 27 +- .../products/filters/ProductFilters.tsx | 18 +- .../products/filters/SortDropdownContent.tsx | 7 +- src/components/search/SearchBar.tsx | 10 +- src/contexts/StoreContext.tsx | 8 +- src/i18n/request.ts | 30 + src/lib/utils/filters.ts | 53 +- src/lib/utils/price-buckets.ts | 30 +- src/types/next-intl.d.ts | 7 + 57 files changed, 2753 insertions(+), 573 deletions(-) create mode 100644 messages/de.json create mode 100644 messages/en.json create mode 100644 messages/pl.json create mode 100644 scripts/check-locale-parity.ts create mode 100644 src/i18n/request.ts create mode 100644 src/types/next-intl.d.ts diff --git a/messages/de.json b/messages/de.json new file mode 100644 index 00000000..4e7e2886 --- /dev/null +++ b/messages/de.json @@ -0,0 +1,382 @@ +{ + "common": { + "subtotal": "Zwischensumme", + "shipping": "Versand", + "tax": "Steuer", + "discount": "Rabatt", + "total": "Gesamt", + "adjustments": "Anpassungen", + "edit": "Bearbeiten", + "remove": "Entfernen", + "cancel": "Abbrechen", + "back": "Zurück", + "continue": "Weiter", + "loading": "Laden...", + "apply": "Anwenden", + "saving": "Speichern...", + "processing": "Verarbeitung...", + "continueShopping": "Weiter einkaufen", + "orderSummary": "Bestellübersicht", + "selectCountry": "Land auswählen", + "myAccount": "Mein Konto" + }, + "header": { + "categories": "Kategorien", + "openCart": "Warenkorb öffnen", + "account": "Konto" + }, + "footer": { + "description": "Ein moderner E-Commerce-Shop powered by Spree Commerce und Next.js.", + "shop": "Shop", + "allProducts": "Alle Produkte", + "categories": "Kategorien", + "account": "Konto", + "myAccount": "Mein Konto", + "orderHistory": "Bestellverlauf", + "cart": "Warenkorb", + "copyright": "© {year} {storeName}. Unterstützt durch Spree Commerce." + }, + "home": { + "welcome": "Willkommen bei Spree Store", + "heroDescription": "Entdecken Sie großartige Produkte mit unserem modernen E-Commerce-Erlebnis powered by Spree Commerce.", + "shopNow": "Jetzt einkaufen", + "browseCategories": "Kategorien durchsuchen", + "featuredProducts": "Ausgewählte Produkte", + "viewAll": "Alle ansehen", + "qualityProducts": "Qualitätsprodukte", + "qualityDescription": "Sorgfältig kuratierte Auswahl der besten Produkte.", + "fastShipping": "Schneller Versand", + "shippingDescription": "Schnelle und zuverlässige Lieferung bis an Ihre Haustür.", + "support": "24/7 Support", + "supportDescription": "Unser Team ist jederzeit für Sie da." + }, + "cart": { + "cart": "Warenkorb", + "closeCart": "Warenkorb schließen", + "emptyCart": "Ihr Warenkorb ist leer", + "emptyCartDescription": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.", + "removeItem": "Artikel entfernen", + "decreaseQuantity": "Menge verringern", + "increaseQuantity": "Menge erhöhen", + "shippingNote": "Versand und Steuern werden an der Kasse berechnet", + "checkout": "Zur Kasse", + "viewCart": "Warenkorb anzeigen", + "shoppingCart": "Warenkorb", + "proceedToCheckout": "Zur Kasse gehen", + "itemCount": "{count, plural, one {# Artikel} other {# Artikel}}", + "shippingCalculatedAtCheckout": "Wird an der Kasse berechnet" + }, + "products": { + "allProducts": "Alle Produkte", + "browseCollection": "Entdecken Sie unsere gesamte Kollektion", + "searchResultsFor": "Suchergebnisse für \"{query}\"", + "productsFound": "{count, plural, one {# Produkt} other {# Produkte}} gefunden", + "noProductsFound": "Keine Produkte gefunden", + "tryAdjustingFilters": "Versuchen Sie, Ihre Filter anzupassen", + "noMatchingProducts": "Wir konnten keine Produkte finden, die zu \"{query}\" passen", + "showingProducts": "{shown} von {total} Produkten werden angezeigt", + "loadingMore": "Weitere werden geladen...", + "noMoreProducts": "Keine weiteren Produkte zum Laden", + "filters": "Filter", + "sortBy": "Sortieren nach", + "resetFilters": "Filter zurücksetzen", + "price": "Preis", + "availability": "Verfügbarkeit", + "inStock": "Auf Lager", + "outOfStock": "Nicht auf Lager", + "min": "Min", + "max": "Max", + "range": "Bereich: {min} - {max}", + "clear": "Löschen", + "productCount": "{count, plural, one {# Produkt} other {# Produkte}}", + "sale": "Sale", + "outOfStockVariant": "(Nicht auf Lager)", + "addToCart": "In den Warenkorb", + "adding": "Wird hinzugefügt...", + "description": "Beschreibung", + "details": "Details", + "sku": "Artikelnr.", + "options": "Optionen", + "search": "Suche...", + "searching": "Suche läuft...", + "viewAllResultsFor": "Alle Ergebnisse für \"{query}\" anzeigen", + "manual": "Manuell", + "bestSelling": "Bestseller", + "priceLowHigh": "Preis (niedrig-hoch)", + "priceHighLow": "Preis (hoch-niedrig)", + "newest": "Neueste", + "oldest": "Älteste", + "nameAZ": "Name (A-Z)", + "nameZA": "Name (Z-A)", + "sortUnknown": "Sonstige", + "categories": "Kategorien", + "noCategoriesFound": "Keine Kategorien gefunden.", + "noCategoriesInGroup": "Keine Kategorien in dieser Gruppe.", + "subcategories": "{count, plural, one {# Unterkategorie} other {# Unterkategorien}}", + "noImageAvailable": "Kein Bild verfügbar", + "clickToZoom": "Zum Vergrößern klicken", + "noProductsMatchingFilters": "Keine Produkte gefunden, die Ihren Filtern entsprechen.", + "decreaseQuantity": "Menge verringern", + "increaseQuantity": "Menge erhöhen", + "carouselPrev": "Vorherige Produkte", + "carouselNext": "Nächste Produkte", + "lightboxClose": "Lightbox schließen", + "lightboxPrev": "Vorheriges Bild", + "lightboxNext": "Nächstes Bild", + "subcategoriesHeading": "Unterkategorien", + "sort": "Sortieren", + "clearAll": "Alle löschen", + "clearAllFiltersCount": "Alle Filter löschen ({count})", + "showResults": "Ergebnisse anzeigen", + "closeFilters": "Filter schließen", + "anyAvailability": "Beliebige Verfügbarkeit", + "customPrice": "Benutzerdefiniert", + "priceUnder": "Unter {price}", + "priceAbove": "{price}+", + "priceRangeBucket": "{min} – {max}" + }, + "navigation": { + "home": "Startseite", + "categories": "Kategorien", + "breadcrumb": "Brotkrümelnavigation" + }, + "checkout": { + "checkout": "Kasse", + "orderNumber": "Bestellung #{number}", + "checkoutError": "Kassenfehler", + "returnToCart": "Zurück zum Warenkorb", + "emptyCart": "Ihr Warenkorb ist leer", + "emptyCartDescription": "Fügen Sie einige Artikel zu Ihrem Warenkorb hinzu, bevor Sie zur Kasse gehen.", + "stepShipping": "Versand", + "stepDelivery": "Lieferung", + "stepPayment": "Zahlung", + "contactInformation": "Kontaktinformationen", + "emailAddress": "E-Mail-Adresse", + "emailPlaceholder": "sie@beispiel.de", + "usingAccountEmail": "Verwendung Ihrer Konto-E-Mail-Adresse", + "signInPrompt": "Haben Sie bereits ein Konto?", + "signIn": "Anmelden", + "signInDescription": "um auf Ihre gespeicherten Adressen und Bestellhistorie zuzugreifen.", + "shippingAddress": "Lieferadresse", + "continueToDelivery": "Weiter zur Lieferung", + "shippingMethod": "Versandmethode", + "loadingShippingOptions": "Versandoptionen werden geladen...", + "shipmentOf": "Sendung {current} von {total}", + "shipsFrom": "Versand von {location}", + "phone": "Telefon: {phone}", + "billingAddress": "Rechnungsadresse", + "sameAsShipping": "Wie Lieferadresse", + "paymentMethod": "Zahlungsmethode", + "loadingPaymentForm": "Zahlungsformular wird geladen...", + "addNewPaymentMethod": "Neue Zahlungsmethode hinzufügen", + "endingIn": "endet auf", + "exp": "Gültig bis", + "default": "Standard", + "noPaymentMethods": "Für diese Bestellung sind keine Zahlungsmethoden verfügbar.", + "payNow": "Jetzt bezahlen", + "failedToInitPayment": "Zahlung konnte nicht initialisiert werden. Bitte versuchen Sie es erneut.", + "failedToCreateSession": "Zahlungssitzung konnte nicht erstellt werden.", + "paymentError": "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "orderNotFound": "Bestellung nicht gefunden oder Sie haben keinen Zugriff darauf.", + "failedToLoadCheckout": "Kasse konnte nicht geladen werden. Bitte versuchen Sie es erneut.", + "orderNotFoundAfterPayment": "Bestellung nach Zahlung nicht gefunden. Bitte kontaktieren Sie den Support.", + "failedToSaveAddress": "Adresse konnte nicht gespeichert werden", + "failedToProceed": "Weiter zum nächsten Schritt fehlgeschlagen", + "generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + "failedToSelectRate": "Versandrate konnte nicht ausgewählt werden", + "failedToSaveBilling": "Rechnungsadresse konnte nicht gespeichert werden", + "failedToSaveBillingRetry": "Rechnungsadresse konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "failedToCompleteOrder": "Bestellung konnte nicht abgeschlossen werden", + "stripeNotLoaded": "Stripe wurde noch nicht geladen", + "paymentProcessingError": "Bei der Zahlung ist ein Fehler aufgetreten.", + "failedToCompletePaymentSession": "Zahlungssitzung konnte nicht abgeschlossen werden" + }, + "address": { + "firstName": "Vorname", + "lastName": "Nachname", + "company": "Firma (optional)", + "address": "Adresse", + "streetAddress": "Straßenadresse", + "apartment": "Wohnung, Suite usw. (optional)", + "city": "Stadt", + "country": "Land", + "selectCountry": "Land auswählen", + "stateProvince": "Bundesland / Provinz", + "selectState": "Bundesland auswählen", + "stateOrProvince": "Bundesland oder Provinz", + "zipCode": "Postleitzahl", + "phone": "Telefon (optional)", + "selectAnAddress": "Adresse auswählen", + "useDifferentAddress": "Andere Adresse verwenden", + "editAddress": "Adresse bearbeiten", + "addNewAddress": "Neue Adresse hinzufügen", + "saveAddress": "Adresse speichern", + "failedToSave": "Adresse konnte nicht gespeichert werden" + }, + "account": { + "myAccount": "Mein Konto", + "signInDescription": "Melden Sie sich an, um auf Ihr Konto und Ihre Bestellhistorie zuzugreifen.", + "email": "E-Mail", + "password": "Passwort", + "signIn": "Anmelden", + "signingIn": "Anmeldung...", + "invalidCredentials": "Ungültige E-Mail oder Passwort", + "dontHaveAccount": "Noch kein Konto?", + "signUp": "Registrieren", + "accountOverview": "Kontoübersicht", + "orderHistory": "Bestellverlauf", + "orderHistoryDescription": "Frühere Bestellungen ansehen und Sendungen verfolgen", + "addresses": "Adressen", + "addressesDescription": "Versand- und Rechnungsadressen verwalten", + "paymentMethods": "Zahlungsmethoden", + "paymentMethodsDescription": "Gespeicherte Kreditkarten verwalten", + "profile": "Profil", + "profileDescription": "Persönliche Daten aktualisieren", + "signOut": "Abmelden", + "overview": "Übersicht", + "orders": "Bestellungen", + "giftCards": "Geschenkkarten", + "addNewAddress": "Neue Adresse hinzufügen", + "noAddresses": "Keine gespeicherten Adressen", + "noAddressesDescription": "Adressen, die Sie beim Checkout hinzufügen, erscheinen hier.", + "defaultAddress": "Standard", + "deleteConfirm": "Sind Sie sicher, dass Sie diese Adresse löschen möchten?", + "setAsDefault": "Als Standard festlegen", + "showPassword": "Passwort anzeigen", + "hidePassword": "Passwort verbergen" + }, + "register": { + "createAccount": "Konto erstellen", + "signUpDescription": "Registrieren Sie sich, um mit dem Einkaufen zu beginnen.", + "confirmPassword": "Passwort bestätigen", + "creatingAccount": "Konto wird erstellt...", + "passwordsDontMatch": "Passwörter stimmen nicht überein", + "passwordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein", + "registrationFailed": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "alreadyHaveAccount": "Haben Sie bereits ein Konto?", + "signIn": "Anmelden" + }, + "orders": { + "orderHistory": "Bestellverlauf", + "noOrders": "Noch keine Bestellungen", + "noOrdersDescription": "Wenn Sie Bestellungen aufgeben, erscheinen sie hier.", + "startShopping": "Einkaufen starten", + "order": "Bestellung", + "date": "Datum", + "status": "Status", + "actions": "Aktionen", + "view": "Ansehen", + "orderNotFound": "Bestellung nicht gefunden", + "backToOrders": "Zurück zu Bestellungen", + "placedOn": "Bestellt am {date}", + "orderItems": "Bestellartikel", + "qty": "Menge: {quantity}", + "orderAgain": "Erneut bestellen", + "deliveryAddress": "Lieferadresse", + "shippingMethod": "Versandmethode", + "shippedFrom": "Versand von {location}", + "trackItems": "Sendung verfolgen", + "shipmentCanceled": "Diese Sendung wurde storniert.", + "noTrackingInfo": "Keine Tracking-Informationen vorhanden.", + "paymentInformation": "Zahlungsinformationen", + "cardEndingIn": "{label} endet auf {digits}", + "cardExpires": "Gültig bis {month}/{year}", + "storeCreditApplied": "Angewendet {amount} — {remaining} verbleibend", + "billingAddress": "Rechnungsadresse", + "orderNotFoundDescription": "Die Bestellung, die Sie suchen, existiert nicht.", + "storeCredit": "Store-Guthaben", + "specialInstructions": "Besondere Hinweise", + "payment": "Zahlung", + "shipment": "Sendung", + "shipped": "Versendet", + "partiallyShipped": "Teilweise versendet", + "delivered": "Zugestellt", + "ready": "Bereit", + "pending": "Ausstehend", + "canceled": "Storniert", + "backorder": "Nachbestellung", + "paid": "Bezahlt", + "balanceDue": "Offener Betrag", + "creditOwed": "Guthaben geschuldet", + "failed": "Fehlgeschlagen", + "void": "Storniert", + "notAvailable": "k. A." + }, + "orderPlaced": { + "thanksForOrder": "Vielen Dank für Ihre Bestellung, {name}!", + "thanksForOrderAnonymous": "Vielen Dank für Ihre Bestellung!", + "orderNumber": "Bestellung #{number}", + "emailConfirmation": "Sie erhalten in Kürze eine E-Mail-Bestätigung.", + "orderItems": "Bestellartikel", + "contactInformation": "Kontaktinformationen", + "shippingAddress": "Lieferadresse", + "billingAddress": "Rechnungsadresse", + "orderNotFound": "Bestellung nicht gefunden", + "failedToLoad": "Bestelldetails konnten nicht geladen werden." + }, + "coupon": { + "placeholder": "Geschenkkarte oder Rabattcode", + "apply": "Anwenden", + "remove": "Entfernen", + "invalidCode": "Ungültiger Gutscheincode", + "applyFailed": "Gutschein konnte nicht angewendet werden. Bitte versuchen Sie es erneut.", + "failedToRemove": "Gutscheincode konnte nicht entfernt werden", + "removeCoupon": "Gutschein {code} entfernen", + "applying": "Wird angewendet...", + "removing": "Wird entfernt..." + }, + "checkoutLayout": { + "backToStore": "Zurück zum Shop", + "showOrderSummary": "Bestellübersicht anzeigen", + "hideOrderSummary": "Bestellübersicht ausblenden", + "allRightsReserved": "© {year} {storeName}. Alle Rechte vorbehalten." + }, + "profile": { + "profile": "Profil", + "firstName": "Vorname", + "lastName": "Nachname", + "emailAddress": "E-Mail-Adresse", + "saveChanges": "Änderungen speichern", + "saving": "Speichern...", + "profileUpdated": "Profil erfolgreich aktualisiert!", + "accountInformation": "Kontoinformationen", + "accountId": "Konto-ID", + "loadingProfile": "Profil wird geladen...", + "failedToUpdate": "Profil konnte nicht aktualisiert werden" + }, + "creditCards": { + "paymentMethods": "Zahlungsmethoden", + "noCards": "Keine Zahlungsmethoden gespeichert", + "noCardsDescription": "Zahlungsmethoden werden automatisch gespeichert, wenn Sie einen Kauf tätigen.", + "expires": "Gültig bis", + "default": "Standard", + "removing": "Wird entfernt...", + "secureInfo": "Ihre Zahlungsinformationen werden sicher gespeichert.", + "deleteConfirm": "Sind Sie sicher, dass Sie diese Karte entfernen möchten?", + "cardMaskedLabel": "{label} endet auf {digits}, gültig bis {month}/{year}" + }, + "giftCards": { + "giftCards": "Geschenkkarten", + "noGiftCards": "Keine Geschenkkarten", + "noGiftCardsDescription": "Sie haben noch keine Geschenkkarten.", + "active": "Aktiv", + "partiallyUsed": "Teilweise eingelöst", + "fullyRedeemed": "Vollständig eingelöst", + "canceled": "Storniert", + "expired": "Abgelaufen", + "noExpiration": "Kein Ablaufdatum", + "remaining": "verbleibend", + "usedAmount": "Verwendet: {amount}", + "totalAmountWithValue": "Gesamt: {amount}", + "addedOnDate": "Hinzugefügt am {date}", + "fullyRedeemedOnDate": "Vollständig eingelöst am {date}", + "activeGiftCards": "Aktive Geschenkkarten", + "expiredRedeemed": "Abgelaufen / Eingelöst", + "copy": "Kopieren", + "copied": "Kopiert!", + "copyCodeToClipboard": "Code in die Zwischenablage kopieren", + "expiresOn": "Läuft ab am {date}", + "percentUsed": "{percent}% verwendet", + "giftCardsHelpText": "Geschenkkarten können beim Checkout zum Bezahlen Ihrer Bestellungen verwendet werden. Das verbleibende Guthaben wird für zukünftige Einkäufe gespeichert." + } +} diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 00000000..ec05253f --- /dev/null +++ b/messages/en.json @@ -0,0 +1,382 @@ +{ + "common": { + "subtotal": "Subtotal", + "shipping": "Shipping", + "tax": "Tax", + "discount": "Discount", + "total": "Total", + "adjustments": "Adjustments", + "edit": "Edit", + "remove": "Remove", + "cancel": "Cancel", + "back": "Back", + "continue": "Continue", + "loading": "Loading...", + "apply": "Apply", + "saving": "Saving...", + "processing": "Processing...", + "continueShopping": "Continue Shopping", + "orderSummary": "Order Summary", + "selectCountry": "Select Country", + "myAccount": "My Account" + }, + "header": { + "categories": "Categories", + "openCart": "Open cart", + "account": "Account" + }, + "footer": { + "description": "A modern e-commerce storefront powered by Spree Commerce and Next.js.", + "shop": "Shop", + "allProducts": "All Products", + "categories": "Categories", + "account": "Account", + "myAccount": "My Account", + "orderHistory": "Order History", + "cart": "Cart", + "copyright": "© {year} {storeName}. Powered by Spree Commerce." + }, + "home": { + "welcome": "Welcome to Spree Store", + "heroDescription": "Discover amazing products with our modern e-commerce experience powered by Spree Commerce.", + "shopNow": "Shop Now", + "browseCategories": "Browse Categories", + "featuredProducts": "Featured Products", + "viewAll": "View all", + "qualityProducts": "Quality Products", + "qualityDescription": "Carefully curated selection of the best products.", + "fastShipping": "Fast Shipping", + "shippingDescription": "Quick and reliable delivery to your doorstep.", + "support": "24/7 Support", + "supportDescription": "Our team is here to help you anytime." + }, + "cart": { + "cart": "Cart", + "closeCart": "Close cart", + "emptyCart": "Your cart is empty", + "emptyCartDescription": "Looks like you haven't added anything to your cart yet.", + "removeItem": "Remove item", + "decreaseQuantity": "Decrease quantity", + "increaseQuantity": "Increase quantity", + "shippingNote": "Shipping and taxes calculated at checkout", + "checkout": "Checkout", + "viewCart": "View Cart", + "shoppingCart": "Shopping Cart", + "proceedToCheckout": "Proceed to Checkout", + "itemCount": "{count, plural, one {# item} other {# items}}", + "shippingCalculatedAtCheckout": "Calculated at checkout" + }, + "products": { + "allProducts": "All Products", + "browseCollection": "Browse our complete collection", + "searchResultsFor": "Search results for \"{query}\"", + "productsFound": "{count, plural, one {# product} other {# products}} found", + "noProductsFound": "No products found", + "tryAdjustingFilters": "Try adjusting your filters", + "noMatchingProducts": "We couldn't find any products matching \"{query}\"", + "showingProducts": "Showing {shown} of {total} products", + "loadingMore": "Loading more...", + "noMoreProducts": "No more products to load", + "filters": "Filters", + "sortBy": "Sort by", + "resetFilters": "Reset filters", + "price": "Price", + "availability": "Availability", + "inStock": "In Stock", + "outOfStock": "Out of Stock", + "min": "Min", + "max": "Max", + "range": "Range: {min} - {max}", + "clear": "Clear", + "productCount": "{count, plural, one {# product} other {# products}}", + "sale": "Sale", + "outOfStockVariant": "(Out of stock)", + "addToCart": "Add to Cart", + "adding": "Adding...", + "description": "Description", + "details": "Details", + "sku": "SKU", + "options": "Options", + "search": "Search...", + "searching": "Searching...", + "viewAllResultsFor": "View all results for \"{query}\"", + "manual": "Manual", + "bestSelling": "Best Selling", + "priceLowHigh": "Price (low-high)", + "priceHighLow": "Price (high-low)", + "newest": "Newest", + "oldest": "Oldest", + "nameAZ": "Name (A-Z)", + "nameZA": "Name (Z-A)", + "sortUnknown": "Other", + "categories": "Categories", + "noCategoriesFound": "No categories found.", + "noCategoriesInGroup": "No categories in this group.", + "subcategories": "{count, plural, one {# subcategory} other {# subcategories}}", + "noImageAvailable": "No image available", + "clickToZoom": "Click to zoom", + "noProductsMatchingFilters": "No products found matching your filters.", + "decreaseQuantity": "Decrease quantity", + "increaseQuantity": "Increase quantity", + "carouselPrev": "Previous products", + "carouselNext": "Next products", + "lightboxClose": "Close lightbox", + "lightboxPrev": "Previous image", + "lightboxNext": "Next image", + "subcategoriesHeading": "Subcategories", + "sort": "Sort", + "clearAll": "Clear all", + "clearAllFiltersCount": "Clear all filters ({count})", + "showResults": "Show results", + "closeFilters": "Close filters", + "anyAvailability": "Any availability", + "customPrice": "Custom", + "priceUnder": "Under {price}", + "priceAbove": "{price}+", + "priceRangeBucket": "{min} – {max}" + }, + "navigation": { + "home": "Home", + "categories": "Categories", + "breadcrumb": "Breadcrumb" + }, + "checkout": { + "checkout": "Checkout", + "orderNumber": "Order #{number}", + "checkoutError": "Checkout Error", + "returnToCart": "Return to Cart", + "emptyCart": "Your Cart is Empty", + "emptyCartDescription": "Add some items to your cart before checking out.", + "stepShipping": "Shipping", + "stepDelivery": "Delivery", + "stepPayment": "Payment", + "contactInformation": "Contact Information", + "emailAddress": "Email address", + "emailPlaceholder": "you@example.com", + "usingAccountEmail": "Using your account email address", + "signInPrompt": "Already have an account?", + "signIn": "Sign in", + "signInDescription": "to access your saved addresses and order history.", + "shippingAddress": "Shipping Address", + "continueToDelivery": "Continue to Delivery", + "shippingMethod": "Shipping Method", + "loadingShippingOptions": "Loading shipping options...", + "shipmentOf": "Shipment {current} of {total}", + "shipsFrom": "Ships from {location}", + "phone": "Phone: {phone}", + "billingAddress": "Billing Address", + "sameAsShipping": "Same as shipping address", + "paymentMethod": "Payment Method", + "loadingPaymentForm": "Loading payment form...", + "addNewPaymentMethod": "Add new payment method", + "endingIn": "ending in", + "exp": "Exp", + "default": "Default", + "noPaymentMethods": "No payment methods available for this order.", + "payNow": "Pay Now", + "failedToInitPayment": "Failed to initialize payment. Please try again.", + "failedToCreateSession": "Failed to create payment session.", + "paymentError": "An error occurred during payment. Please try again.", + "orderNotFound": "Order not found or you don't have access to it.", + "failedToLoadCheckout": "Failed to load checkout. Please try again.", + "orderNotFoundAfterPayment": "Order not found after payment. Please contact support.", + "failedToSaveAddress": "Failed to save address", + "failedToProceed": "Failed to proceed to next step", + "generalError": "An error occurred. Please try again.", + "failedToSelectRate": "Failed to select shipping rate", + "failedToSaveBilling": "Failed to save billing address", + "failedToSaveBillingRetry": "Failed to save billing address. Please try again.", + "failedToCompleteOrder": "Failed to complete order", + "stripeNotLoaded": "Stripe has not loaded yet", + "paymentProcessingError": "An error occurred during payment.", + "failedToCompletePaymentSession": "Failed to complete payment session" + }, + "address": { + "firstName": "First name", + "lastName": "Last name", + "company": "Company (optional)", + "address": "Address", + "streetAddress": "Street address", + "apartment": "Apartment, suite, etc. (optional)", + "city": "City", + "country": "Country", + "selectCountry": "Select a country", + "stateProvince": "State / Province", + "selectState": "Select a state", + "stateOrProvince": "State or province", + "zipCode": "ZIP / Postal code", + "phone": "Phone (optional)", + "selectAnAddress": "Select an address", + "useDifferentAddress": "Use a different address", + "editAddress": "Edit Address", + "addNewAddress": "Add New Address", + "saveAddress": "Save Address", + "failedToSave": "Failed to save address" + }, + "account": { + "myAccount": "My Account", + "signInDescription": "Sign in to access your account and order history.", + "email": "Email", + "password": "Password", + "signIn": "Sign In", + "signingIn": "Signing in...", + "invalidCredentials": "Invalid email or password", + "dontHaveAccount": "Don't have an account?", + "signUp": "Sign up", + "accountOverview": "Account Overview", + "orderHistory": "Order History", + "orderHistoryDescription": "View your past orders and track shipments", + "addresses": "Addresses", + "addressesDescription": "Manage your shipping and billing addresses", + "paymentMethods": "Payment Methods", + "paymentMethodsDescription": "Manage your saved credit cards", + "profile": "Profile", + "profileDescription": "Update your personal information", + "signOut": "Sign Out", + "overview": "Overview", + "orders": "Orders", + "giftCards": "Gift Cards", + "addNewAddress": "Add New Address", + "noAddresses": "No saved addresses", + "noAddressesDescription": "Addresses you add during checkout will appear here.", + "defaultAddress": "Default", + "deleteConfirm": "Are you sure you want to delete this address?", + "setAsDefault": "Set as default", + "showPassword": "Show password", + "hidePassword": "Hide password" + }, + "register": { + "createAccount": "Create Account", + "signUpDescription": "Sign up to start shopping with us.", + "confirmPassword": "Confirm Password", + "creatingAccount": "Creating account...", + "passwordsDontMatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters", + "registrationFailed": "Registration failed. Please try again.", + "alreadyHaveAccount": "Already have an account?", + "signIn": "Sign in" + }, + "orders": { + "orderHistory": "Order History", + "noOrders": "No orders yet", + "noOrdersDescription": "When you place orders, they will appear here.", + "startShopping": "Start Shopping", + "order": "Order", + "date": "Date", + "status": "Status", + "actions": "Actions", + "view": "View", + "orderNotFound": "Order not found", + "backToOrders": "Back to orders", + "placedOn": "Placed on {date}", + "orderItems": "Order Items", + "qty": "Qty: {quantity}", + "orderAgain": "Order again", + "deliveryAddress": "Delivery Address", + "shippingMethod": "Shipping Method", + "shippedFrom": "Shipped from {location}", + "trackItems": "Track Items", + "shipmentCanceled": "This shipment has been canceled.", + "noTrackingInfo": "No tracking information present.", + "paymentInformation": "Payment Information", + "cardEndingIn": "{label} ending in {digits}", + "cardExpires": "Expires {month}/{year}", + "storeCreditApplied": "Applied {amount} — {remaining} remaining", + "billingAddress": "Billing Address", + "orderNotFoundDescription": "The order you're looking for doesn't exist.", + "storeCredit": "Store Credit", + "specialInstructions": "Special Instructions", + "payment": "Payment", + "shipment": "Shipment", + "shipped": "Shipped", + "partiallyShipped": "Partially Shipped", + "delivered": "Delivered", + "ready": "Ready", + "pending": "Pending", + "canceled": "Canceled", + "backorder": "Backorder", + "paid": "Paid", + "balanceDue": "Balance Due", + "creditOwed": "Credit Owed", + "failed": "Failed", + "void": "Void", + "notAvailable": "N/A" + }, + "orderPlaced": { + "thanksForOrder": "Thanks for your order, {name}!", + "thanksForOrderAnonymous": "Thanks for your order!", + "orderNumber": "Order #{number}", + "emailConfirmation": "You will receive an email confirmation shortly.", + "orderItems": "Order Items", + "contactInformation": "Contact Information", + "shippingAddress": "Shipping Address", + "billingAddress": "Billing Address", + "orderNotFound": "Order not found", + "failedToLoad": "Failed to load order details." + }, + "coupon": { + "placeholder": "Gift card or discount code", + "apply": "Apply", + "remove": "Remove", + "invalidCode": "Invalid coupon code", + "applyFailed": "Could not apply coupon. Please try again.", + "failedToRemove": "Failed to remove coupon code", + "removeCoupon": "Remove coupon {code}", + "applying": "Applying...", + "removing": "Removing..." + }, + "checkoutLayout": { + "backToStore": "Back to store", + "showOrderSummary": "Show order summary", + "hideOrderSummary": "Hide order summary", + "allRightsReserved": "© {year} {storeName}. All rights reserved." + }, + "profile": { + "profile": "Profile", + "firstName": "First Name", + "lastName": "Last Name", + "emailAddress": "Email Address", + "saveChanges": "Save Changes", + "saving": "Saving...", + "profileUpdated": "Profile updated successfully!", + "accountInformation": "Account Information", + "accountId": "Account ID", + "loadingProfile": "Loading profile...", + "failedToUpdate": "Failed to update profile" + }, + "creditCards": { + "paymentMethods": "Payment Methods", + "noCards": "No payment methods saved", + "noCardsDescription": "Payment methods are saved automatically when you make a purchase.", + "expires": "Expires", + "default": "Default", + "removing": "Removing...", + "secureInfo": "Your payment information is securely stored.", + "deleteConfirm": "Are you sure you want to remove this card?", + "cardMaskedLabel": "{label} ending in {digits}, expires {month}/{year}" + }, + "giftCards": { + "giftCards": "Gift Cards", + "noGiftCards": "No gift cards", + "noGiftCardsDescription": "You don't have any gift cards yet.", + "active": "Active", + "partiallyUsed": "Partially Used", + "fullyRedeemed": "Fully Redeemed", + "canceled": "Canceled", + "expired": "Expired", + "noExpiration": "No expiration", + "remaining": "remaining", + "usedAmount": "Used: {amount}", + "totalAmountWithValue": "Total: {amount}", + "addedOnDate": "Added on {date}", + "fullyRedeemedOnDate": "Fully redeemed on {date}", + "activeGiftCards": "Active Gift Cards", + "expiredRedeemed": "Expired / Redeemed", + "copy": "Copy", + "copied": "Copied!", + "copyCodeToClipboard": "Copy code to clipboard", + "expiresOn": "Expires {date}", + "percentUsed": "{percent}% used", + "giftCardsHelpText": "Gift cards can be used during checkout to pay for your orders. The remaining balance will be saved for future purchases." + } +} diff --git a/messages/pl.json b/messages/pl.json new file mode 100644 index 00000000..424ff17e --- /dev/null +++ b/messages/pl.json @@ -0,0 +1,382 @@ +{ + "common": { + "subtotal": "Suma częściowa", + "shipping": "Wysyłka", + "tax": "Podatek", + "discount": "Rabat", + "total": "Suma", + "adjustments": "Korekty", + "edit": "Edytuj", + "remove": "Usuń", + "cancel": "Anuluj", + "back": "Wstecz", + "continue": "Kontynuuj", + "loading": "Ładowanie...", + "apply": "Zastosuj", + "saving": "Zapisywanie...", + "processing": "Przetwarzanie...", + "continueShopping": "Kontynuuj zakupy", + "orderSummary": "Podsumowanie zamówienia", + "selectCountry": "Wybierz kraj", + "myAccount": "Moje konto" + }, + "header": { + "categories": "Kategorie", + "openCart": "Otwórz koszyk", + "account": "Konto" + }, + "footer": { + "description": "Nowoczesny sklep internetowy oparty na Spree Commerce i Next.js.", + "shop": "Sklep", + "allProducts": "Wszystkie produkty", + "categories": "Kategorie", + "account": "Konto", + "myAccount": "Moje konto", + "orderHistory": "Historia zamówień", + "cart": "Koszyk", + "copyright": "© {year} {storeName}. Zasilany przez Spree Commerce." + }, + "home": { + "welcome": "Witaj w Sklepie Spree", + "heroDescription": "Odkryj niesamowite produkty dzięki nowoczesnemu sklepowi internetowemu opartemu na Spree Commerce.", + "shopNow": "Kup teraz", + "browseCategories": "Przeglądaj kategorie", + "featuredProducts": "Polecane produkty", + "viewAll": "Zobacz wszystkie", + "qualityProducts": "Produkty wysokiej jakości", + "qualityDescription": "Starannie wyselekcjonowana kolekcja najlepszych produktów.", + "fastShipping": "Szybka wysyłka", + "shippingDescription": "Szybka i niezawodna dostawa pod Twoje drzwi.", + "support": "Wsparcie 24/7", + "supportDescription": "Nasz zespół jest do Twojej dyspozycji o każdej porze." + }, + "cart": { + "cart": "Koszyk", + "closeCart": "Zamknij koszyk", + "emptyCart": "Twój koszyk jest pusty", + "emptyCartDescription": "Wygląda na to, że nie dodałeś jeszcze nic do koszyka.", + "removeItem": "Usuń produkt", + "decreaseQuantity": "Zmniejsz ilość", + "increaseQuantity": "Zwiększ ilość", + "shippingNote": "Koszty wysyłki i podatki zostaną obliczone przy kasie", + "checkout": "Do kasy", + "viewCart": "Zobacz koszyk", + "shoppingCart": "Koszyk zakupowy", + "proceedToCheckout": "Przejdź do kasy", + "itemCount": "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktów}}", + "shippingCalculatedAtCheckout": "Obliczona przy kasie" + }, + "products": { + "allProducts": "Wszystkie produkty", + "browseCollection": "Przeglądaj naszą pełną kolekcję", + "searchResultsFor": "Wyniki wyszukiwania dla \"{query}\"", + "productsFound": "{count, plural, one {# produkt} few {# produkty} other {# produktów}}", + "noProductsFound": "Nie znaleziono produktów", + "tryAdjustingFilters": "Spróbuj zmienić filtry", + "noMatchingProducts": "Nie znaleźliśmy produktów pasujących do \"{query}\"", + "showingProducts": "Wyświetlanie {shown} z {total} produktów", + "loadingMore": "Ładowanie kolejnych...", + "noMoreProducts": "Brak kolejnych produktów", + "filters": "Filtry", + "sortBy": "Sortuj wg", + "resetFilters": "Resetuj filtry", + "price": "Cena", + "availability": "Dostępność", + "inStock": "Dostępny", + "outOfStock": "Niedostępny", + "min": "Min", + "max": "Maks", + "range": "Zakres: {min} - {max}", + "clear": "Wyczyść", + "productCount": "{count, plural, =0 {0 produktów} one {# produkt} few {# produkty} many {# produktów} other {# produktów}}", + "sale": "Wyprzedaż", + "outOfStockVariant": "(Niedostępny)", + "addToCart": "Dodaj do koszyka", + "adding": "Dodawanie...", + "description": "Opis", + "details": "Szczegóły", + "sku": "SKU", + "options": "Opcje", + "search": "Szukaj...", + "searching": "Wyszukiwanie...", + "viewAllResultsFor": "Zobacz wszystkie wyniki dla \"{query}\"", + "manual": "Ręcznie", + "bestSelling": "Bestsellery", + "priceLowHigh": "Cena (rosnąco)", + "priceHighLow": "Cena (malejąco)", + "newest": "Najnowsze", + "oldest": "Najstarsze", + "nameAZ": "Nazwa (A-Z)", + "nameZA": "Nazwa (Z-A)", + "sortUnknown": "Inne", + "categories": "Kategorie", + "noCategoriesFound": "Nie znaleziono kategorii.", + "noCategoriesInGroup": "Brak kategorii w tej grupie.", + "subcategories": "{count, plural, =0 {0 podkategorii} one {# podkategoria} few {# podkategorie} many {# podkategorii} other {# podkategorii}}", + "noImageAvailable": "Brak zdjęcia", + "clickToZoom": "Kliknij aby powiększyć", + "noProductsMatchingFilters": "Nie znaleziono produktów pasujących do wybranych filtrów.", + "decreaseQuantity": "Zmniejsz ilość", + "increaseQuantity": "Zwiększ ilość", + "carouselPrev": "Poprzednie produkty", + "carouselNext": "Następne produkty", + "lightboxClose": "Zamknij podgląd", + "lightboxPrev": "Poprzednie zdjęcie", + "lightboxNext": "Następne zdjęcie", + "subcategoriesHeading": "Podkategorie", + "sort": "Sortuj", + "clearAll": "Wyczyść wszystko", + "clearAllFiltersCount": "Wyczyść wszystkie filtry ({count})", + "showResults": "Pokaż wyniki", + "closeFilters": "Zamknij filtry", + "anyAvailability": "Dowolna dostępność", + "customPrice": "Niestandardowa", + "priceUnder": "Poniżej {price}", + "priceAbove": "{price}+", + "priceRangeBucket": "{min} – {max}" + }, + "navigation": { + "home": "Strona główna", + "categories": "Kategorie", + "breadcrumb": "Ścieżka nawigacji" + }, + "checkout": { + "checkout": "Kasa", + "orderNumber": "Zamówienie #{number}", + "checkoutError": "Błąd kasy", + "returnToCart": "Wróć do koszyka", + "emptyCart": "Twój koszyk jest pusty", + "emptyCartDescription": "Dodaj produkty do koszyka przed złożeniem zamówienia.", + "stepShipping": "Wysyłka", + "stepDelivery": "Dostawa", + "stepPayment": "Płatność", + "contactInformation": "Dane kontaktowe", + "emailAddress": "Adres e-mail", + "emailPlaceholder": "ty@przyklad.pl", + "usingAccountEmail": "Używasz adresu e-mail Twojego konta", + "signInPrompt": "Masz już konto?", + "signIn": "Zaloguj się", + "signInDescription": "aby uzyskać dostęp do zapisanych adresów i historii zamówień.", + "shippingAddress": "Adres wysyłki", + "continueToDelivery": "Przejdź do dostawy", + "shippingMethod": "Metoda wysyłki", + "loadingShippingOptions": "Ładowanie opcji wysyłki...", + "shipmentOf": "Przesyłka {current} z {total}", + "shipsFrom": "Wysyłka z {location}", + "phone": "Telefon: {phone}", + "billingAddress": "Adres rozliczeniowy", + "sameAsShipping": "Taki sam jak adres wysyłki", + "paymentMethod": "Metoda płatności", + "loadingPaymentForm": "Ładowanie formularza płatności...", + "addNewPaymentMethod": "Dodaj nową metodę płatności", + "endingIn": "kończąca się na", + "exp": "Ważna do", + "default": "Domyślna", + "noPaymentMethods": "Brak dostępnych metod płatności dla tego zamówienia.", + "payNow": "Zapłać teraz", + "failedToInitPayment": "Nie udało się zainicjować płatności. Spróbuj ponownie.", + "failedToCreateSession": "Nie udało się utworzyć sesji płatności.", + "paymentError": "Wystąpił błąd podczas płatności. Spróbuj ponownie.", + "orderNotFound": "Zamówienie nie zostało znalezione lub nie masz do niego dostępu.", + "failedToLoadCheckout": "Nie udało się załadować kasy. Spróbuj ponownie.", + "orderNotFoundAfterPayment": "Zamówienie nie zostało znalezione po płatności. Skontaktuj się z obsługą.", + "failedToSaveAddress": "Nie udało się zapisać adresu", + "failedToProceed": "Nie udało się przejść do następnego kroku", + "generalError": "Wystąpił błąd. Spróbuj ponownie.", + "failedToSelectRate": "Nie udało się wybrać stawki wysyłki", + "failedToSaveBilling": "Nie udało się zapisać adresu rozliczeniowego", + "failedToSaveBillingRetry": "Nie udało się zapisać adresu rozliczeniowego. Spróbuj ponownie.", + "failedToCompleteOrder": "Nie udało się złożyć zamówienia", + "stripeNotLoaded": "Stripe nie został jeszcze załadowany", + "paymentProcessingError": "Wystąpił błąd podczas przetwarzania płatności.", + "failedToCompletePaymentSession": "Nie udało się zakończyć sesji płatności" + }, + "address": { + "firstName": "Imię", + "lastName": "Nazwisko", + "company": "Firma (opcjonalnie)", + "address": "Adres", + "streetAddress": "Ulica i numer", + "apartment": "Mieszkanie, lokal itp. (opcjonalnie)", + "city": "Miasto", + "country": "Kraj", + "selectCountry": "Wybierz kraj", + "stateProvince": "Województwo", + "selectState": "Wybierz województwo", + "stateOrProvince": "Województwo", + "zipCode": "Kod pocztowy", + "phone": "Telefon (opcjonalnie)", + "selectAnAddress": "Wybierz adres", + "useDifferentAddress": "Użyj innego adresu", + "editAddress": "Edytuj adres", + "addNewAddress": "Dodaj nowy adres", + "saveAddress": "Zapisz adres", + "failedToSave": "Nie udało się zapisać adresu" + }, + "account": { + "myAccount": "Moje konto", + "signInDescription": "Zaloguj się, aby uzyskać dostęp do konta i historii zamówień.", + "email": "E-mail", + "password": "Hasło", + "signIn": "Zaloguj się", + "signingIn": "Logowanie...", + "invalidCredentials": "Nieprawidłowy e-mail lub hasło", + "dontHaveAccount": "Nie masz konta?", + "signUp": "Zarejestruj się", + "accountOverview": "Przegląd konta", + "orderHistory": "Historia zamówień", + "orderHistoryDescription": "Przeglądaj poprzednie zamówienia i śledź przesyłki", + "addresses": "Adresy", + "addressesDescription": "Zarządzaj adresami wysyłki i rozliczeń", + "paymentMethods": "Metody płatności", + "paymentMethodsDescription": "Zarządzaj zapisanymi kartami płatniczymi", + "profile": "Profil", + "profileDescription": "Zaktualizuj swoje dane osobowe", + "signOut": "Wyloguj się", + "overview": "Przegląd", + "orders": "Zamówienia", + "giftCards": "Karty podarunkowe", + "addNewAddress": "Dodaj nowy adres", + "noAddresses": "Brak zapisanych adresów", + "noAddressesDescription": "Adresy dodane podczas składania zamówienia pojawią się tutaj.", + "defaultAddress": "Domyślny", + "deleteConfirm": "Czy na pewno chcesz usunąć ten adres?", + "setAsDefault": "Ustaw jako domyślny", + "showPassword": "Pokaż hasło", + "hidePassword": "Ukryj hasło" + }, + "register": { + "createAccount": "Utwórz konto", + "signUpDescription": "Zarejestruj się, aby rozpocząć zakupy.", + "confirmPassword": "Potwierdź hasło", + "creatingAccount": "Tworzenie konta...", + "passwordsDontMatch": "Hasła nie są takie same", + "passwordTooShort": "Hasło musi mieć co najmniej 6 znaków", + "registrationFailed": "Rejestracja nie powiodła się. Spróbuj ponownie.", + "alreadyHaveAccount": "Masz już konto?", + "signIn": "Zaloguj się" + }, + "orders": { + "orderHistory": "Historia zamówień", + "noOrders": "Brak zamówień", + "noOrdersDescription": "Gdy złożysz zamówienie, pojawi się tutaj.", + "startShopping": "Zacznij zakupy", + "order": "Zamówienie", + "date": "Data", + "status": "Status", + "actions": "Akcje", + "view": "Zobacz", + "orderNotFound": "Zamówienie nie zostało znalezione", + "backToOrders": "Wróć do zamówień", + "placedOn": "Złożone {date}", + "orderItems": "Pozycje zamówienia", + "qty": "Ilość: {quantity}", + "orderAgain": "Zamów ponownie", + "deliveryAddress": "Adres dostawy", + "shippingMethod": "Metoda wysyłki", + "shippedFrom": "Wysłane z {location}", + "trackItems": "Śledź przesyłkę", + "shipmentCanceled": "Ta przesyłka została anulowana.", + "noTrackingInfo": "Brak informacji o śledzeniu.", + "paymentInformation": "Informacje o płatności", + "cardEndingIn": "{label} kończąca się na {digits}", + "cardExpires": "Ważna do {month}/{year}", + "storeCreditApplied": "Zastosowano {amount} — {remaining} pozostało", + "billingAddress": "Adres rozliczeniowy", + "orderNotFoundDescription": "Zamówienie, którego szukasz, nie istnieje.", + "storeCredit": "Kredyt sklepowy", + "specialInstructions": "Specjalne instrukcje", + "payment": "Płatność", + "shipment": "Przesyłka", + "shipped": "Wysłano", + "partiallyShipped": "Częściowo wysłano", + "delivered": "Dostarczono", + "ready": "Gotowe", + "pending": "Oczekujące", + "canceled": "Anulowane", + "backorder": "Zamówienie wsteczne", + "paid": "Opłacone", + "balanceDue": "Do zapłaty", + "creditOwed": "Należny kredyt", + "failed": "Nieudane", + "void": "Unieważnione", + "notAvailable": "Brak" + }, + "orderPlaced": { + "thanksForOrder": "Dziękujemy za zamówienie, {name}!", + "thanksForOrderAnonymous": "Dziękujemy za zamówienie!", + "orderNumber": "Zamówienie #{number}", + "emailConfirmation": "Wkrótce otrzymasz potwierdzenie e-mailem.", + "orderItems": "Pozycje zamówienia", + "contactInformation": "Dane kontaktowe", + "shippingAddress": "Adres wysyłki", + "billingAddress": "Adres rozliczeniowy", + "orderNotFound": "Zamówienie nie zostało znalezione", + "failedToLoad": "Nie udało się załadować szczegółów zamówienia." + }, + "coupon": { + "placeholder": "Karta podarunkowa lub kod rabatowy", + "apply": "Zastosuj", + "remove": "Usuń", + "invalidCode": "Nieprawidłowy kod kuponu", + "applyFailed": "Nie udało się zastosować kuponu. Spróbuj ponownie.", + "failedToRemove": "Nie udało się usunąć kodu kuponu", + "removeCoupon": "Usuń kupon {code}", + "applying": "Stosowanie...", + "removing": "Usuwanie..." + }, + "checkoutLayout": { + "backToStore": "Wróć do sklepu", + "showOrderSummary": "Pokaż podsumowanie zamówienia", + "hideOrderSummary": "Ukryj podsumowanie zamówienia", + "allRightsReserved": "© {year} {storeName}. Wszelkie prawa zastrzeżone." + }, + "profile": { + "profile": "Profil", + "firstName": "Imię", + "lastName": "Nazwisko", + "emailAddress": "Adres e-mail", + "saveChanges": "Zapisz zmiany", + "saving": "Zapisywanie...", + "profileUpdated": "Profil został zaktualizowany!", + "accountInformation": "Informacje o koncie", + "accountId": "ID konta", + "loadingProfile": "Ładowanie profilu...", + "failedToUpdate": "Nie udało się zaktualizować profilu" + }, + "creditCards": { + "paymentMethods": "Metody płatności", + "noCards": "Brak zapisanych metod płatności", + "noCardsDescription": "Metody płatności są zapisywane automatycznie podczas zakupów.", + "expires": "Ważna do", + "default": "Domyślna", + "removing": "Usuwanie...", + "secureInfo": "Twoje dane płatnicze są bezpiecznie przechowywane.", + "deleteConfirm": "Czy na pewno chcesz usunąć tę kartę?", + "cardMaskedLabel": "{label} kończąca się na {digits}, ważna do {month}/{year}" + }, + "giftCards": { + "giftCards": "Karty podarunkowe", + "noGiftCards": "Brak kart podarunkowych", + "noGiftCardsDescription": "Nie masz jeszcze żadnych kart podarunkowych.", + "active": "Aktywna", + "partiallyUsed": "Częściowo wykorzystana", + "fullyRedeemed": "W pełni wykorzystana", + "canceled": "Anulowana", + "expired": "Wygasła", + "noExpiration": "Bez daty ważności", + "remaining": "pozostało", + "usedAmount": "Wykorzystano: {amount}", + "totalAmountWithValue": "Łącznie: {amount}", + "addedOnDate": "Dodano {date}", + "fullyRedeemedOnDate": "W pełni wykorzystano {date}", + "activeGiftCards": "Aktywne karty podarunkowe", + "expiredRedeemed": "Wygasłe / Wykorzystane", + "copy": "Kopiuj", + "copied": "Skopiowano!", + "copyCodeToClipboard": "Skopiuj kod do schowka", + "expiresOn": "Wygasa {date}", + "percentUsed": "{percent}% wykorzystano", + "giftCardsHelpText": "Karty podarunkowe mogą być używane podczas składania zamówienia do opłacenia zakupów. Pozostałe saldo zostanie zapisane na przyszłe zakupy." + } +} diff --git a/next.config.ts b/next.config.ts index 84b3a538..a8d5c92e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,8 @@ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { env: { @@ -43,8 +46,10 @@ const nextConfig: NextConfig = { }, }; +const configWithIntl = withNextIntl(nextConfig); + export default process.env.SENTRY_DSN - ? withSentryConfig(nextConfig, { + ? withSentryConfig(configWithIntl, { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, @@ -62,4 +67,4 @@ export default process.env.SENTRY_DSN // Disables the Sentry SDK build-time telemetry telemetry: false, }) - : nextConfig; + : configWithIntl; diff --git a/package-lock.json b/package-lock.json index f299b43f..a9886bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.577.0", "next": "^16", + "next-intl": "^4.8.3", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19", @@ -180,6 +181,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -863,6 +865,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -903,6 +906,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1649,6 +1653,58 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz", + "integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.0", + "@formatjs/intl-localematcher": "0.8.1", + "decimal.js": "^10.6.0", + "tslib": "^2.8.1" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz", + "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz", + "integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "3.1.1", + "@formatjs/icu-skeleton-parser": "2.1.1", + "tslib": "^2.8.1" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz", + "integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "3.1.1", + "tslib": "^2.8.1" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz", + "integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.0", + "tslib": "^2.8.1" + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", @@ -2244,7 +2300,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2492,6 +2547,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2588,6 +2644,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2609,6 +2666,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2621,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3044,6 +3103,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3060,6 +3120,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.1", "@opentelemetry/resources": "2.5.1", @@ -3077,6 +3138,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3096,6 +3158,301 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@prisma/instrumentation": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", @@ -5015,6 +5372,12 @@ "win32" ] }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -5478,74 +5841,242 @@ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@spree/next": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.9.0.tgz", + "integrity": "sha512-Qa0+OW3xfRbookWTwev/ZgzUG8xNvrnAtVyoHxEbDy3XHZcWmIw6AMd/ph6QPv7uzx47Y6NhGFwZ4ljkqEn9DA==", + "license": "MIT", + "peerDependencies": { + "@spree/sdk": ">=0.9.0", + "next": ">=15.0.0", + "react": ">=19.0.0" + } + }, + "node_modules/@spree/sdk": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.9.0.tgz", + "integrity": "sha512-RWYNyhsVYrevubpeXt5NfWJrc9xr0GG9zj2TrxU5fT6PsZEh4VK8DYlgfG9C/q2Vp6a9p99QiZ5z09bZ9YlW5g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=4.7.0", + "zod": ">=3.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", + "integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz", + "integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.16" + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@spree/next": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.9.0.tgz", - "integrity": "sha512-Qa0+OW3xfRbookWTwev/ZgzUG8xNvrnAtVyoHxEbDy3XHZcWmIw6AMd/ph6QPv7uzx47Y6NhGFwZ4ljkqEn9DA==", - "license": "MIT", - "peerDependencies": { - "@spree/sdk": ">=0.9.0", - "next": ">=15.0.0", - "react": ">=19.0.0" + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@spree/sdk": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.9.0.tgz", - "integrity": "sha512-RWYNyhsVYrevubpeXt5NfWJrc9xr0GG9zj2TrxU5fT6PsZEh4VK8DYlgfG9C/q2Vp6a9p99QiZ5z09bZ9YlW5g==", - "license": "MIT", + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typescript": ">=4.7.0", - "zod": ">=3.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "zod": { - "optional": true - } + "node": ">=10" } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } }, - "node_modules/@stripe/react-stripe-js": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", - "integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@stripe/stripe-js": ">=8.0.0 <9.0.0", - "react": ">=16.8.0 <20.0.0", - "react-dom": ">=16.8.0 <20.0.0" + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@stripe/stripe-js": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz", - "integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==", - "license": "MIT", + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.16" + "node": ">=10" } }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5555,6 +6086,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -5996,8 +6536,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6076,7 +6615,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -6087,7 +6625,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -6103,8 +6640,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mysql": { "version": "2.15.27", @@ -6150,6 +6686,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6160,6 +6697,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6332,7 +6870,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -6342,29 +6879,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6375,15 +6908,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6396,7 +6927,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6406,7 +6936,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6415,15 +6944,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6440,7 +6967,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -6454,7 +6980,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6467,7 +6992,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6482,7 +7006,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -6492,15 +7015,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/accepts": { "version": "2.0.0", @@ -6545,6 +7066,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6566,7 +7088,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -6591,6 +7112,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6607,7 +7129,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -6625,7 +7146,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -6648,7 +7168,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6813,6 +7332,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6831,8 +7351,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/bundle-name": { "version": "4.1.0", @@ -6943,7 +7462,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -7312,7 +7830,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/dedent": { @@ -7401,7 +7918,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7427,8 +7943,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -7630,7 +8145,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -7657,7 +8171,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -7670,7 +8183,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -7680,7 +8192,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -7705,7 +8216,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -8226,8 +8736,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/gopd": { "version": "1.2.0", @@ -8261,7 +8770,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8301,6 +8809,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -8400,6 +8909,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/icu-minify": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", + "integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8453,6 +8977,18 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/intl-messageformat": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz", + "integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "3.1.1", + "@formatjs/fast-memoize": "3.1.0", + "@formatjs/icu-messageformat-parser": "3.5.1", + "tslib": "^2.8.1" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -8687,7 +9223,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -9297,7 +9832,6 @@ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -9385,7 +9919,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9481,7 +10014,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9491,7 +10023,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -9687,14 +10218,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -9743,6 +10274,82 @@ } } }, + "node_modules/next-intl": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz", + "integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.8.3", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.8.3", + "po-parser": "^2.1.1", + "use-intl": "^4.8.3" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz", + "integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -9781,6 +10388,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -10236,6 +10849,12 @@ "node": ">=16.20.0" } }, + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -10334,7 +10953,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10577,6 +11195,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10586,6 +11205,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10598,8 +11218,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -10797,6 +11416,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10927,7 +11547,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -11357,7 +11976,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -11539,7 +12157,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11623,7 +12240,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -11642,7 +12258,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -11675,8 +12290,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/third-party-capital": { "version": "1.0.20", @@ -11890,6 +12504,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12004,6 +12619,27 @@ } } }, + "node_modules/use-intl": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz", + "integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.8.3", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -12078,6 +12714,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12243,7 +12880,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -12276,7 +12912,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12325,7 +12960,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -12334,8 +12968,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/whatwg-mimetype": { "version": "5.0.0", @@ -12618,6 +13251,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7aae6d40..996d7a87 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "format": "biome format . --write", "check": "biome check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "check:locales": "npx tsx scripts/check-locale-parity.ts" }, "dependencies": { "@next/third-parties": "^16.1.6", @@ -23,6 +24,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.577.0", "next": "^16", + "next-intl": "^4.8.3", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19", diff --git a/scripts/check-locale-parity.ts b/scripts/check-locale-parity.ts new file mode 100644 index 00000000..a69387c0 --- /dev/null +++ b/scripts/check-locale-parity.ts @@ -0,0 +1,79 @@ +/** + * Checks that all locale JSON files have the same set of keys as en.json. + * Run: npx tsx scripts/check-locale-parity.ts + */ +import fs from "node:fs"; +import path from "node:path"; + +const MESSAGES_DIR = path.resolve(__dirname, "../messages"); +const BASE_LOCALE = "en"; + +function getNestedKeys(obj: Record, prefix = ""): string[] { + const keys: string[] = []; + for (const key of Object.keys(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + keys.push(...getNestedKeys(value as Record, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys; +} + +const baseFile = path.join(MESSAGES_DIR, `${BASE_LOCALE}.json`); +let baseMessages: Record; +try { + baseMessages = JSON.parse(fs.readFileSync(baseFile, "utf-8")); +} catch (err) { + console.error(`Failed to parse base locale file ${baseFile}:`, err); + process.exit(1); +} +const baseKeys = new Set(getNestedKeys(baseMessages)); + +const localeFiles = fs + .readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json") && f !== `${BASE_LOCALE}.json`); + +let hasError = false; + +for (const file of localeFiles) { + const locale = file.replace(".json", ""); + let messages: Record; + try { + messages = JSON.parse( + fs.readFileSync(path.join(MESSAGES_DIR, file), "utf-8"), + ); + } catch (err) { + console.error(`Failed to parse locale file ${file}:`, err); + hasError = true; + continue; + } + const localeKeys = new Set(getNestedKeys(messages)); + + const missing = [...baseKeys].filter((k) => !localeKeys.has(k)); + const extra = [...localeKeys].filter((k) => !baseKeys.has(k)); + + if (missing.length > 0) { + console.error(`[${locale}] Missing ${missing.length} key(s):`); + for (const k of missing) console.error(` - ${k}`); + hasError = true; + } + + if (extra.length > 0) { + console.error(`[${locale}] Extra ${extra.length} key(s):`); + for (const k of extra) console.error(` + ${k}`); + hasError = true; + } + + if (missing.length === 0 && extra.length === 0) { + console.log(`[${locale}] OK — all keys match ${BASE_LOCALE}.json`); + } +} + +if (hasError) { + process.exit(1); +} else { + console.log("\nAll locale files are in sync."); +} diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 2975be45..2af87db0 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -10,6 +10,7 @@ import type { import { CircleAlert } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { use, useCallback, useEffect, useRef, useState } from "react"; import { AddressStep } from "@/components/checkout/AddressStep"; import { CouponCode } from "@/components/checkout/CouponCode"; @@ -41,11 +42,7 @@ import { } from "@/lib/data/payment"; import { extractBasePath } from "@/lib/utils/path"; -const CHECKOUT_STEPS = [ - { id: "address", label: "Shipping" }, - { id: "delivery", label: "Delivery" }, - { id: "payment", label: "Payment" }, -]; +const CHECKOUT_STEP_IDS = ["address", "delivery", "payment"] as const; interface CheckoutPageProps { params: Promise<{ @@ -90,6 +87,13 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const pathname = usePathname(); const basePath = extractBasePath(pathname); const { setSummaryContent } = useCheckout(); + const t = useTranslations("checkout"); + + const stepLabels: Record = { + address: t("stepShipping"), + delivery: t("stepDelivery"), + payment: t("stepPayment"), + }; const [order, setOrder] = useState(null); const [shipments, setShipments] = useState([]); @@ -187,7 +191,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { : { data: [] as Country[] }; if (!orderData) { - setError("Order not found or you don't have access to it."); + setError(t("orderNotFound")); setLoading(false); return; } @@ -220,12 +224,12 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { return orderData; } catch { - setError("Failed to load checkout. Please try again."); + setError(t("failedToLoadCheckout")); return null; } finally { setLoading(false); } - }, [orderId, urlCountry, basePath, router]); + }, [orderId, urlCountry, basePath, router, t]); useEffect(() => { loadOrder(); @@ -479,14 +483,14 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { return (

- Checkout Error + {t("checkoutError")}

{error}

- Return to Cart + {t("returnToCart")}
); @@ -499,22 +503,23 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { return (

- Your Cart is Empty + {t("emptyCart")}

-

- Add some items to your cart before checking out. -

+

{t("emptyCartDescription")}

- Continue Shopping + {t("returnToCart")}
); } - const steps = CHECKOUT_STEPS; + const steps = CHECKOUT_STEP_IDS.map((id) => ({ + id, + label: stepLabels[id] || id.charAt(0).toUpperCase() + id.slice(1), + })); const currentStepIndex = steps.findIndex((s) => s.id === currentStep); const previousStepId = currentStepIndex > 0 ? steps[currentStepIndex - 1].id : undefined; @@ -523,7 +528,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { <> {/* Header */}
-

Checkout

+

{t("checkout")}

{/* Step indicator */} diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[country]/[locale]/(checkout)/layout.tsx index ba1f59d3..dfbfa292 100644 --- a/src/app/[country]/[locale]/(checkout)/layout.tsx +++ b/src/app/[country]/[locale]/(checkout)/layout.tsx @@ -4,15 +4,18 @@ import { ArrowLeft, ChevronDown, ShoppingBag } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { CheckoutProvider, CheckoutSummary } from "@/contexts/CheckoutContext"; +import { getStoreName } from "@/lib/seo"; import { extractBasePath } from "@/lib/utils/path"; -const storeName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store"; +const storeName = getStoreName(); function CheckoutHeader() { const pathname = usePathname(); const basePath = extractBasePath(pathname); + const t = useTranslations("checkoutLayout"); return (
@@ -28,22 +31,22 @@ function CheckoutHeader() {
); } function CheckoutFooter() { - const currentYear = new Date().getFullYear(); + const t = useTranslations("checkoutLayout"); return (

- © {currentYear} {storeName}. All rights reserved. + {t("allRightsReserved", { year: new Date().getFullYear(), storeName })}

); @@ -51,6 +54,7 @@ function CheckoutFooter() { function MobileSummaryToggle() { const [isOpen, setIsOpen] = useState(false); + const t = useTranslations("checkoutLayout"); return (
@@ -61,7 +65,7 @@ function MobileSummaryToggle() { > - {isOpen ? "Hide order summary" : "Show order summary"} + {isOpen ? t("hideOrderSummary") : t("showOrderSummary")} (null); const [loading, setLoading] = useState(true); @@ -72,14 +75,14 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { // Analytics failure must not break the order confirmation UX } } else { - setError("Order not found."); + setError(t("orderNotFound")); } setLoading(false); } } catch { if (!cancelled) { loadedRef.current = true; - setError("Failed to load order details."); + setError(t("failedToLoad")); setLoading(false); } } @@ -90,7 +93,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { return () => { cancelled = true; }; - }, [orderId, searchParams]); + }, [orderId, searchParams, t]); if (loading) { return ( @@ -107,10 +110,10 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { return (

- {error || "Order not found"} + {error || t("orderNotFound")}

); @@ -125,19 +128,22 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {

- Thanks for your order - {customerName ? `, ${customerName.split(" ")[0]}` : ""}! + {customerName + ? t("thanksForOrder", { name: customerName.split(" ")[0] }) + : t("thanksForOrderAnonymous")}

-

Order #{order.number}

-

- You will receive an email confirmation shortly. +

+ {t("orderNumber", { number: order.number })}

+

{t("emailConfirmation")}

{/* Order Items */}
-

Order Items

+

+ {t("orderItems")} +

    {order.items?.map((item) => ( @@ -170,27 +176,27 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { {/* Totals */}
    - Subtotal + {tc("subtotal")} {order.display_item_total}
    - Shipping + {tc("shipping")} {order.display_ship_total}
    {order.promo_total && Number.parseFloat(order.promo_total) !== 0 && (
    - Discount + {tc("discount")} {order.display_promo_total}
    )}
    - Tax + {tc("tax")} {order.display_tax_total}
    - Total + {tc("total")} {order.display_total} @@ -202,7 +208,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {

    - Contact Information + {t("contactInformation")}

    @@ -214,7 +220,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { {order.ship_address && (

    - Shipping Address + {t("shippingAddress")}

    @@ -242,7 +248,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { {order.bill_address && (

    - Billing Address + {t("billingAddress")}

    @@ -270,7 +276,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { {/* Actions */}

    diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx index 0d15ed45..823ad496 100644 --- a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx @@ -2,6 +2,7 @@ import type { Address, AddressParams, Country } from "@spree/sdk"; import { MapPin, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { AddressEditModal } from "@/components/checkout/AddressEditModal"; import { @@ -33,6 +34,8 @@ function AddressCard({ onEdit: () => void; onDelete: () => void; }) { + const t = useTranslations("account"); + const tc = useTranslations("common"); const [deleting, setDeleting] = useState(false); const handleDelete = async () => { @@ -63,20 +66,19 @@ function AddressCard({
    Delete address? - This will permanently delete this address. This action cannot - be undone. + {t("deleteConfirm")} @@ -94,6 +96,7 @@ function AddressCard({ } export default function AddressesPage() { + const t = useTranslations("account"); const [addresses, setAddresses] = useState([]); const [countries, setCountries] = useState([]); const [loading, setLoading] = useState(true); @@ -167,7 +170,9 @@ export default function AddressesPage() { if (loading) { return (
    -

    Addresses

    +

    + {t("addresses")} +

    {[1, 2].map((i) => (
    -

    Addresses

    +

    {t("addresses")}

    @@ -197,12 +202,10 @@ export default function AddressesPage() {

    - No addresses saved + {t("noAddresses")}

    -

    - Add an address for faster checkout. -

    - +

    {t("noAddressesDescription")}

    +
    ) : (
    diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index cd940e9c..5cf10c9c 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -2,6 +2,7 @@ import type { CreditCard as SpreeCreditCard } from "@spree/sdk"; import { CreditCard, Lock } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { @@ -26,6 +27,8 @@ function CreditCardItem({ card: SpreeCreditCard; onDelete: () => void; }) { + const t = useTranslations("creditCards"); + const tc = useTranslations("common"); const [deleting, setDeleting] = useState(false); const handleDelete = async () => { @@ -51,7 +54,7 @@ function CreditCardItem({ {getCardLabel(card.cc_type)} ending in {card.last_digits}

    - Expires {String(card.month).padStart(2, "0")}/{card.year} + {t("expires")} {String(card.month).padStart(2, "0")}/{card.year}

    {card.name && (

    {card.name}

    @@ -61,28 +64,26 @@ function CreditCardItem({
    {card.default && ( - Default + {t("default")} )} Remove payment method? - This will remove the {getCardLabel(card.cc_type)} ending in{" "} - {card.last_digits} from your account. This action cannot be - undone. + {t("deleteConfirm")} Cancel - Remove + {tc("remove")} @@ -94,6 +95,7 @@ function CreditCardItem({ } export default function CreditCardsPage() { + const t = useTranslations("creditCards"); const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); @@ -125,7 +127,7 @@ export default function CreditCardsPage() { return (

    - Payment Methods + {t("paymentMethods")}

    {[1, 2].map((i) => ( @@ -144,17 +146,17 @@ export default function CreditCardsPage() { return (
    -

    Payment Methods

    +

    + {t("paymentMethods")} +

    {cards.length === 0 ? (

    - No payment methods saved + {t("noCards")}

    -

    - Payment methods are saved automatically when you make a purchase. -

    +

    {t("noCardsDescription")}

    ) : (
    @@ -171,8 +173,7 @@ export default function CreditCardsPage() {

    - Your payment information is securely stored and encrypted. We never - store your full card number. + {t("secureInfo")}

    diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx index 2de995a4..6d2a019d 100644 --- a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx @@ -2,6 +2,7 @@ import type { GiftCard } from "@spree/sdk"; import { Check, ClipboardCopy, Gift, Info } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { getGiftCards } from "@/lib/data/gift-cards"; @@ -22,25 +23,8 @@ function getStateColor(state: string, expired: boolean): string { } } -function getStateLabel(state: string): string { - switch (state) { - case "active": - return "Active"; - case "partially_redeemed": - return "Partially Used"; - case "redeemed": - return "Fully Redeemed"; - case "canceled": - return "Canceled"; - case "expired": - return "Expired"; - default: - return state; - } -} - function formatDate(dateString: string | null): string { - if (!dateString) return "No expiration"; + if (!dateString) return ""; const date = new Date(dateString); return date.toLocaleDateString("en-US", { year: "numeric", @@ -50,6 +34,7 @@ function formatDate(dateString: string | null): string { } function CopyButton({ code }: { code: string }) { + const t = useTranslations("giftCards"); const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -80,12 +65,12 @@ function CopyButton({ code }: { code: string }) { {copied ? ( <> - Copied! + {t("copied")} ) : ( <> - Copy + {t("copy")} )} @@ -93,11 +78,29 @@ function CopyButton({ code }: { code: string }) { } function GiftCardItem({ card }: { card: GiftCard }) { + const t = useTranslations("giftCards"); const usagePercentage = Number(card.amount) > 0 ? Math.round((Number(card.amount_used) / Number(card.amount)) * 100) : 0; + function getStateLabel(state: string): string { + switch (state) { + case "active": + return t("active"); + case "partially_redeemed": + return t("partiallyUsed"); + case "redeemed": + return t("fullyRedeemed"); + case "canceled": + return t("canceled"); + case "expired": + return t("expired"); + default: + return state; + } + } + return (
    @@ -115,15 +118,15 @@ function GiftCardItem({ card }: { card: GiftCard }) {

    {card.expires_at - ? `Expires ${formatDate(card.expires_at)}` - : "No expiration date"} + ? t("expiresOn", { date: formatDate(card.expires_at) }) + : t("noExpiration")}

    {card.display_amount_remaining}

    -

    remaining

    +

    {t("remaining")}

    @@ -131,9 +134,11 @@ function GiftCardItem({ card }: { card: GiftCard }) {
    - Used: {card.display_amount_used} + {t("usedAmount", { amount: card.display_amount_used })} + + + {t("totalAmountWithValue", { amount: card.display_amount })} - Total: {card.display_amount}
    -

    {usagePercentage}% used

    +

    + {t("percentUsed", { percent: usagePercentage })} +

    {/* Additional info */}
    - Added on {formatDate(card.created_at)} + {t("addedOnDate", { date: formatDate(card.created_at) })} {card.redeemed_at && ( Fully redeemed on {formatDate(card.redeemed_at)} )} @@ -160,6 +167,7 @@ function GiftCardItem({ card }: { card: GiftCard }) { } export default function GiftCardsPage() { + const t = useTranslations("giftCards"); const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); @@ -175,7 +183,9 @@ export default function GiftCardsPage() { if (loading) { return (
    -

    Gift Cards

    +

    + {t("giftCards")} +

    {[1, 2].map((i) => (
    -

    Gift Cards

    +

    + {t("giftCards")} +

    {cards.length === 0 ? (

    - No gift cards + {t("noGiftCards")}

    -

    - You don't have any gift cards associated with your account yet. -

    +

    {t("noGiftCardsDescription")}

    ) : ( <> @@ -216,7 +226,7 @@ export default function GiftCardsPage() { {activeCards.length > 0 && (

    - Active Gift Cards ({activeCards.length}) + {t("activeGiftCards")} ({activeCards.length})

    {activeCards.map((card) => ( @@ -230,7 +240,7 @@ export default function GiftCardsPage() { {inactiveCards.length > 0 && (

    - Expired / Redeemed ({inactiveCards.length}) + {t("expiredRedeemed")} ({inactiveCards.length})

    {inactiveCards.map((card) => ( diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index e213ff9a..249c93b6 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -12,27 +12,30 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; -const navItems: { +function useNavItems(t: ReturnType>): { href: string; label: string; icon: LucideIcon; -}[] = [ - { href: "/account", label: "Overview", icon: Home }, - { href: "/account/orders", label: "Orders", icon: ShoppingBag }, - { href: "/account/addresses", label: "Addresses", icon: MapPin }, - { - href: "/account/credit-cards", - label: "Payment Methods", - icon: CreditCard, - }, - { href: "/account/gift-cards", label: "Gift Cards", icon: Gift }, - { href: "/account/profile", label: "Profile", icon: User }, -]; +}[] { + return [ + { href: "/account", label: t("overview"), icon: Home }, + { href: "/account/orders", label: t("orders"), icon: ShoppingBag }, + { href: "/account/addresses", label: t("addresses"), icon: MapPin }, + { + href: "/account/credit-cards", + label: t("paymentMethods"), + icon: CreditCard, + }, + { href: "/account/gift-cards", label: t("giftCards"), icon: Gift }, + { href: "/account/profile", label: t("profile"), icon: User }, + ]; +} function ContentSkeleton() { return ( @@ -66,6 +69,8 @@ function AccountShell({ onLogout, isLoading, }: AccountShellProps) { + const t = useTranslations("account"); + const navItems = useNavItems(t); return (
    @@ -84,7 +89,7 @@ function AccountShell({

    {user?.first_name ? `${user.first_name} ${user.last_name || ""}`.trim() - : "My Account"} + : t("myAccount")}

    {user?.email} @@ -125,7 +130,7 @@ function AccountShell({

    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx index dd121543..a20fb91e 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx @@ -11,6 +11,7 @@ import type { import { ChevronLeft, CircleAlert } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { use, useEffect, useState } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -47,6 +48,7 @@ function getShipmentStatusColor(state: string): string { } function PaymentSourceInfo({ payment }: { payment: Payment }) { + const t = useTranslations("orders"); const source = payment.source; if (payment.source_type === "credit_card" && source) { @@ -60,10 +62,16 @@ function PaymentSourceInfo({ payment }: { payment: Payment }) { />

    - {getCardLabel(card.cc_type)} ending in {card.last_digits} + {t("cardEndingIn", { + label: getCardLabel(card.cc_type), + digits: card.last_digits, + })}

    - Expires {String(card.month).padStart(2, "0")}/{card.year} + {t("cardExpires", { + month: String(card.month).padStart(2, "0"), + year: card.year, + })}

    @@ -74,7 +82,7 @@ function PaymentSourceInfo({ payment }: { payment: Payment }) { const credit = source as StoreCredit; return (
    -

    Store Credit

    +

    {t("storeCredit")}

    Applied {payment.display_amount} — {credit.display_amount_remaining}{" "} remaining @@ -119,6 +127,7 @@ function LineItemCard({ item: Order["items"][number]; basePath: string; }) { + const t = useTranslations("orders"); return (

    {/* Image */} @@ -147,7 +156,9 @@ function LineItemCard({ {item.options_text && (

    {item.options_text}

    )} -

    Qty: {item.quantity}

    +

    + {t("qty", { quantity: item.quantity })} +

    {/* Shipment header */} @@ -183,7 +195,7 @@ function ShipmentBlock({ {shipAddress && (

    - Delivery Address + {t("deliveryAddress")}

    @@ -191,7 +203,7 @@ function ShipmentBlock({

    - Shipping Method + {t("shippingMethod")}

    {shipment.shipping_method?.name || "Canceled"} @@ -215,12 +227,12 @@ function ShipmentBlock({ target="_blank" rel="noopener noreferrer" > - Track Items + {t("trackItems")} ) : ( )}

    @@ -265,6 +277,8 @@ interface OrderDetailPageProps { } export default function OrderDetailPage({ params }: OrderDetailPageProps) { + const t = useTranslations("orders"); + const tc = useTranslations("common"); const { id } = use(params); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -297,7 +311,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { return (

    - Order not found + {t("orderNotFound")}

    The order you're looking for doesn't exist. @@ -306,7 +320,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { href={`${basePath}/account/orders`} className="text-primary hover:text-primary font-medium" > - Back to orders + {t("backToOrders")}

    ); @@ -322,7 +336,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { className="text-sm text-gray-500 hover:text-gray-700 mb-4 inline-flex items-center gap-1" > - Back to orders + {t("backToOrders")} {/* Title */} @@ -330,7 +344,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { Order #{order.number}

    - Placed on {formatDate(order.completed_at)} + {t("placedOn", { date: formatDate(order.completed_at) })}

    {/* Shipments with line items */} @@ -360,7 +374,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { {order.special_instructions && (

    - Special Instructions + {t("specialInstructions")}

    {order.special_instructions}

    @@ -372,7 +386,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { {order.bill_address && (

    - Billing Address + {t("billingAddress")}

    @@ -380,7 +394,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { {order.payments && order.payments.length > 0 && (

    - Payment Information + {t("paymentInformation")}

    {order.payments .filter((p) => p.state !== "void" && p.state !== "invalid") @@ -401,16 +415,16 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) {
    - Subtotal + {tc("subtotal")} {order.display_item_total}
    - Shipping + {tc("shipping")} {order.display_ship_total}
    {order.promo_total && Number.parseFloat(order.promo_total) !== 0 && (
    - Discount + {tc("discount")} {order.display_promo_total} @@ -418,12 +432,12 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) { )} {Number.parseFloat(order.tax_total) > 0 && (
    - Tax + {tc("tax")} {order.display_tax_total}
    )}
    - Total + {tc("total")} {order.display_total}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx index 1593614e..bd411258 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx @@ -4,20 +4,40 @@ import type { Order } from "@spree/sdk"; import { ShoppingBag } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { getOrders } from "@/lib/data/orders"; import { extractBasePath } from "@/lib/utils/path"; -function formatDate(dateString: string | null): string { +function formatDate(dateString: string | null, locale: string): string { if (!dateString) return "-"; - return new Date(dateString).toLocaleDateString("en-US", { + return new Date(dateString).toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric", }); } +const PAYMENT_STATE_KEY: Record = { + paid: "paid", + balance_due: "balanceDue", + credit_owed: "creditOwed", + pending: "pending", + failed: "failed", + void: "void", +}; + +const SHIPMENT_STATE_KEY: Record = { + shipped: "shipped", + partial: "partiallyShipped", + delivered: "delivered", + ready: "ready", + pending: "pending", + canceled: "canceled", + backorder: "backorder", +}; + function getPaymentStatusColor(state: string | null): string { switch (state) { case "paid": @@ -49,6 +69,9 @@ function getShipmentStatusColor(state: string | null): string { } export default function OrdersPage() { + const t = useTranslations("orders"); + const tc = useTranslations("common"); + const locale = useLocale(); const pathname = usePathname(); const basePath = extractBasePath(pathname); const [orders, setOrders] = useState([]); @@ -67,7 +90,9 @@ export default function OrdersPage() { if (loading) { return (
    -

    Order History

    +

    + {t("orderHistory")} +

    {[1, 2, 3].map((i) => (
    -

    Order History

    +

    + {t("orderHistory")} +

    {orders.length === 0 ? (

    - No orders yet + {t("noOrders")}

    -

    - When you place orders, they will appear here. -

    +

    {t("noOrdersDescription")}

    ) : ( @@ -107,22 +132,22 @@ export default function OrdersPage() { - Order + {t("order")} - Date + {t("date")} - Payment + {t("payment")} - Shipment + {t("shipment")} - Total + {tc("total")} - Actions + {t("actions")} @@ -136,21 +161,31 @@ export default function OrdersPage() { - {formatDate(order.completed_at)} + {formatDate(order.completed_at, locale)} - {order.payment_state?.replace("_", " ") || "N/A"} + {order.payment_state + ? t( + PAYMENT_STATE_KEY[order.payment_state] || + order.payment_state, + ) + : t("notAvailable")} - {order.shipment_state || "N/A"} + {order.shipment_state + ? t( + SHIPMENT_STATE_KEY[order.shipment_state] || + order.shipment_state, + ) + : t("notAvailable")} @@ -161,7 +196,7 @@ export default function OrdersPage() { diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 63530c7c..8dd4890a 100644 --- a/src/app/[country]/[locale]/(storefront)/account/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/page.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -20,6 +21,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; export default function AccountPage() { + const t = useTranslations("account"); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -47,7 +49,7 @@ export default function AccountPage() { router.push(redirectUrl); } } else { - setError(result.error || "Invalid email or password"); + setError(result.error || t("invalidCredentials")); } setLoading(false); }; @@ -70,10 +72,8 @@ export default function AccountPage() { return (
    -

    My Account

    -

    - Sign in to access your account and order history. -

    +

    {t("myAccount")}

    +

    {t("signInDescription")}

    @@ -86,7 +86,7 @@ export default function AccountPage() { )} - Email + {t("email")} - Password + {t("password")}
    setShowPassword(!showPassword)} aria-label={ - showPassword ? "Hide password" : "Show password" + showPassword ? t("hidePassword") : t("showPassword") } > {showPassword ? ( @@ -140,18 +140,18 @@ export default function AccountPage() { size="lg" className="w-full" > - {loading ? "Signing in..." : "Sign In"} + {loading ? t("signingIn") : t("signIn")}
    - Don't have an account? + {t("dontHaveAccount")} - Sign up + {t("signUp")}
    @@ -163,7 +163,7 @@ export default function AccountPage() { return (

    - Account Overview + {t("accountOverview")}

    @@ -177,10 +177,10 @@ export default function AccountPage() {

    - Order History + {t("orderHistory")}

    - View your past orders and track shipments + {t("orderHistoryDescription")}

    @@ -195,9 +195,11 @@ export default function AccountPage() {
    -

    Addresses

    +

    + {t("addresses")} +

    - Manage your shipping and billing addresses + {t("addressesDescription")}

    @@ -213,10 +215,10 @@ export default function AccountPage() {

    - Payment Methods + {t("paymentMethods")}

    - Manage your saved credit cards + {t("paymentMethodsDescription")}

    @@ -231,9 +233,11 @@ export default function AccountPage() {
    -

    Profile

    +

    + {t("profile")} +

    - Update your personal information + {t("profileDescription")}

    diff --git a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx b/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx index 044d84c0..78930ffc 100644 --- a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx @@ -1,6 +1,7 @@ "use client"; import { CircleAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -23,6 +24,7 @@ function ProfileForm({ }; refreshUser: () => Promise; }) { + const t = useTranslations("profile"); // Initialize form data from user props - no useEffect needed const [formData, setFormData] = useState({ first_name: user.first_name || "", @@ -40,10 +42,10 @@ function ProfileForm({ const result = await updateCustomer(formData); if (result.success) { - toast.success("Profile updated successfully!"); + toast.success(t("profileUpdated")); await refreshUser(); } else { - setError(result.error || "Failed to update profile"); + setError(result.error || t("failedToUpdate")); } setSaving(false); @@ -51,7 +53,7 @@ function ProfileForm({ return (
    -

    Profile

    +

    {t("profile")}

    @@ -64,7 +66,7 @@ function ProfileForm({ )}
    - First Name + {t("firstName")} - Last Name + {t("lastName")} - Email Address + {t("emailAddress")}
    @@ -114,13 +116,15 @@ function ProfileForm({

    - Account Information + {t("accountInformation")}

    -
    Account ID
    +
    + {t("accountId")} +
    {user.id}
    @@ -136,12 +140,13 @@ function ProfileForm({ // Main page component - uses key prop to reset form when user changes export default function ProfilePage() { + const t = useTranslations("profile"); const { user, refreshUser } = useAuth(); if (!user) { return (
    -

    Loading profile...

    +

    {t("loadingProfile")}

    ); } diff --git a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx index 99547a4a..4be9a094 100644 --- a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx @@ -3,6 +3,7 @@ import { CircleAlert, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -12,6 +13,8 @@ import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; export default function RegisterPage() { + const t = useTranslations("register"); + const ta = useTranslations("account"); const router = useRouter(); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -44,12 +47,12 @@ export default function RegisterPage() { setError(null); if (password !== passwordConfirmation) { - setError("Passwords do not match"); + setError(t("passwordsDontMatch")); return; } if (password.length < 6) { - setError("Password must be at least 6 characters"); + setError(t("passwordTooShort")); return; } @@ -66,7 +69,7 @@ export default function RegisterPage() { if (result.success) { router.push(`${basePath}/account`); } else { - setError(result.error || "Registration failed. Please try again."); + setError(result.error || t("registrationFailed")); } } catch { setError("An unexpected error occurred. Please try again."); @@ -78,8 +81,10 @@ export default function RegisterPage() { return (
    -

    Create Account

    -

    Sign up to start shopping with us.

    +

    + {t("createAccount")} +

    +

    {t("signUpDescription")}

    @@ -118,7 +123,7 @@ export default function RegisterPage() {
    - Email + {ta("email")} - Password + {ta("password")}
    setShowPassword(!showPassword)} - aria-label={showPassword ? "Hide password" : "Show password"} + aria-label={ + showPassword ? ta("hidePassword") : ta("showPassword") + } > {showPassword ? ( @@ -162,7 +169,7 @@ export default function RegisterPage() { - Confirm Password + {t("confirmPassword")}
    {showPasswordConfirmation ? ( @@ -204,18 +213,18 @@ export default function RegisterPage() { size="lg" className="w-full" > - {submitting ? "Creating account..." : "Create Account"} + {submitting ? t("creatingAccount") : t("createAccount")}
    - Already have an account? + {t("alreadyHaveAccount")} - Sign in + {t("signIn")}
    diff --git a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryProductsContent.tsx b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryProductsContent.tsx index 55471133..2edebe0a 100644 --- a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryProductsContent.tsx +++ b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryProductsContent.tsx @@ -1,6 +1,7 @@ "use client"; import type { ProductListParams } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { ProductListingLayout } from "@/components/products/ProductListingLayout"; import { useStore } from "@/contexts/StoreContext"; @@ -21,6 +22,7 @@ export function CategoryProductsContent({ categoryName, basePath, }: CategoryProductsContentProps) { + const t = useTranslations("products"); const { currency } = useStore(); const fetchFn = useCallback( @@ -67,7 +69,7 @@ export function CategoryProductsContent({ onFilterChange={listing.handleFilterChange} listId={listId} listName={listName} - emptyMessage="No products found matching your filters." + emptyMessage={t("noProductsMatchingFilters")} /> ); } diff --git a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx index 40f84be7..83f429ef 100644 --- a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { Breadcrumbs } from "@/components/navigation/Breadcrumbs"; import { CategoryBanner } from "@/components/navigation/CategoryBanner"; import { JsonLd } from "@/components/seo/JsonLd"; @@ -30,6 +31,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) { const { country, locale, permalink } = await params; const fullPermalink = permalink.join("/"); const basePath = `/${country}/${locale}`; + const t = await getTranslations("products"); let category; try { @@ -72,7 +74,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) { {category.children && category.children.length > 0 && (

    - Subcategories + {t("subcategoriesHeading")}

    {category.children.map((child) => ( diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index 11fc8021..fcb7822d 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -4,6 +4,7 @@ import type { LineItem } from "@spree/sdk"; import { ShoppingBag } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; @@ -17,6 +18,8 @@ export default function CartPage() { const pathname = usePathname(); const basePath = extractBasePath(pathname); const viewCartFiredRef = useRef(false); + const t = useTranslations("cart"); + const tc = useTranslations("common"); // Track view_cart when cart loads with items useEffect(() => { @@ -57,14 +60,14 @@ export default function CartPage() { strokeWidth={1} />

    - Your cart is empty + {t("emptyCart")}

    -

    - Looks like you haven't added anything to your cart yet. -

    +

    {t("emptyCartDescription")}

    @@ -74,7 +77,9 @@ export default function CartPage() { return (
    -

    Shopping Cart

    +

    + {t("shoppingCart")} +

    {/* Cart Items */} @@ -120,10 +125,10 @@ export default function CartPage() {
    @@ -134,33 +139,37 @@ export default function CartPage() { {/* Order Summary */}
    -

    Order Summary

    +

    + {tc("orderSummary")} +

    -
    Subtotal
    +
    {tc("subtotal")}
    {cart.display_item_total}
    {cart.promo_total && parseFloat(cart.promo_total) < 0 && (
    -
    Discount
    +
    {tc("discount")}
    {cart.display_promo_total}
    )} {cart.ship_total && parseFloat(cart.ship_total) > 0 && (
    -
    Shipping
    +
    {tc("shipping")}
    {cart.display_ship_total}
    )} {cart.tax_total && parseFloat(cart.tax_total) > 0 && (
    -
    Tax
    +
    {tc("tax")}
    {cart.display_tax_total}
    )}
    -
    Total
    +
    + {tc("total")} +
    {cart.display_total}
    @@ -170,11 +179,13 @@ export default function CartPage() {
    diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx index 5de9a508..fa018aca 100644 --- a/src/app/[country]/[locale]/(storefront)/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { ProductCarousel } from "@/components/products/ProductCarousel"; import { Button } from "@/components/ui/button"; import { generateHomeMetadata } from "@/lib/metadata/home"; -import { getStoreDescription, getStoreName } from "@/lib/seo"; interface HomePageProps { params: Promise<{ @@ -22,6 +22,7 @@ export async function generateMetadata({ export default async function HomePage({ params }: HomePageProps) { const { country, locale } = await params; const basePath = `/${country}/${locale}`; + const t = await getTranslations("home"); return (
    @@ -30,17 +31,19 @@ export default async function HomePage({ params }: HomePageProps) {

    - Welcome to {getStoreName()} + {t("welcome")}

    - {getStoreDescription()} + {t("heroDescription")}

    @@ -51,10 +54,10 @@ export default async function HomePage({ params }: HomePageProps) {

    - Featured Products + {t("featuredProducts")}

    diff --git a/src/app/[country]/[locale]/(storefront)/products/ProductsContent.tsx b/src/app/[country]/[locale]/(storefront)/products/ProductsContent.tsx index a2f79be9..2bb80348 100644 --- a/src/app/[country]/[locale]/(storefront)/products/ProductsContent.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/ProductsContent.tsx @@ -2,6 +2,7 @@ import type { ProductListParams } from "@spree/sdk"; import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { ProductListingLayout } from "@/components/products/ProductListingLayout"; import { useStore } from "@/contexts/StoreContext"; @@ -16,6 +17,7 @@ interface ProductsContentProps { export function ProductsContent({ basePath }: ProductsContentProps) { const searchParams = useSearchParams(); const { currency } = useStore(); + const t = useTranslations("products"); const query = searchParams.get("q") || ""; const fetchFn = useCallback( @@ -68,17 +70,18 @@ export function ProductsContent({ basePath }: ProductsContentProps) { {query ? ( <>

    - Search results for “{query}” + {t("searchResultsFor", { query })}

    - {listing.totalCount}{" "} - {listing.totalCount === 1 ? "product" : "products"} found + {t("productsFound", { count: listing.totalCount })}

    ) : ( <> -

    All Products

    -

    Browse our complete collection

    +

    + {t("allProducts")} +

    +

    {t("browseCollection")}

    )}
    @@ -90,9 +93,7 @@ export function ProductsContent({ basePath }: ProductsContentProps) { listId={listId} listName={listName} emptyMessage={ - query - ? `We couldn't find any products matching "${query}"` - : "Try adjusting your filters" + query ? t("noMatchingProducts", { query }) : t("tryAdjustingFilters") } />
    diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx index 0ce3b677..823f2a73 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx +++ b/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx @@ -2,6 +2,7 @@ import type { Product, Image as SpreeImage, Variant } from "@spree/sdk"; import { CircleCheckBig, CircleX, Loader2, ShoppingBag } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { MediaGallery } from "@/components/products/MediaGallery"; import { VariantPicker } from "@/components/products/VariantPicker"; @@ -19,6 +20,7 @@ interface ProductDetailsProps { export function ProductDetails({ product, basePath }: ProductDetailsProps) { const { addItem } = useCart(); const { currency } = useStore(); + const t = useTranslations("products"); // Filter out master variant from variants list const variants = useMemo(() => { @@ -135,7 +137,7 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {strikethroughPrice} - Sale + {t("sale")} )} @@ -146,12 +148,12 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {inStock ? ( - In Stock + {t("inStock")} ) : ( - Out of Stock + {t("outOfStock")} )}
    @@ -187,15 +189,15 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {loading ? ( <> - Adding... + {t("adding")} ) : isPurchasable ? ( <> - Add to Cart + {t("addToCart")} ) : ( - "Out of Stock" + t("outOfStock") )}
    @@ -205,7 +207,7 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { {product.description && (

    - Description + {t("description")}

    -

    Details

    +

    + {t("details")} +

    {selectedVariant?.sku && (
    -
    SKU
    +
    {t("sku")}
    {selectedVariant.sku}
    @@ -228,7 +232,7 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) { )} {selectedVariant?.options_text && (
    -
    Options
    +
    {t("options")}
    {selectedVariant.options_text}
    diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 746352b5..693d6f28 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,14 @@ import { GoogleTagManager } from "@next/third-parties/google"; import type { Metadata } from "next"; import { Geist } from "next/font/google"; +import { cookies } from "next/headers"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; import "./globals.css"; import { CartDrawer } from "@/components/cart/CartDrawer"; import { Toaster } from "@/components/ui/sonner"; import { CartProvider } from "@/contexts/CartContext"; +import { getStoreDescription, getStoreName } from "@/lib/seo"; const gtmId = process.env.GTM_ID; @@ -13,34 +17,38 @@ const geist = Geist({ subsets: ["latin"], }); -const rootStoreName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store"; +const rootStoreName = getStoreName(); export const metadata: Metadata = { title: { template: `%s | ${rootStoreName}`, default: rootStoreName, }, - description: - process.env.NEXT_PUBLIC_STORE_DESCRIPTION || - "A modern e-commerce storefront powered by Spree Commerce and Next.js.", + description: getStoreDescription(), }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const cookieStore = await cookies(); + const locale = cookieStore.get("spree_locale")?.value || "en"; + const messages = await getMessages(); + return ( - + {gtmId && } - - {children} - - - + + + {children} + + + + ); diff --git a/src/components/cart/CartDrawer.tsx b/src/components/cart/CartDrawer.tsx index e5756dda..8d858ec4 100644 --- a/src/components/cart/CartDrawer.tsx +++ b/src/components/cart/CartDrawer.tsx @@ -3,6 +3,7 @@ import { ShoppingBag, Trash, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; @@ -29,6 +30,8 @@ export function CartDrawer() { removeItem, itemCount, } = useCart(); + const t = useTranslations("cart"); + const tc = useTranslations("common"); const pathname = usePathname(); const basePath = extractBasePath(pathname); const viewCartFiredRef = useRef(false); @@ -72,16 +75,18 @@ export function CartDrawer() { - Cart + {t("cart")} {itemCount > 0 && ( - ({itemCount} items) + + ({t("itemCount", { count: itemCount })}) + )} @@ -105,13 +110,14 @@ export function CartDrawer() { className="w-16 h-16 text-gray-300 mb-4" strokeWidth={1} /> -

    Your cart is empty

    +

    {t("emptyCart")}

    +

    {t("emptyCartDescription")}

    - Continue Shopping + {tc("continueShopping")}
    ) : ( @@ -154,7 +160,7 @@ export function CartDrawer() { } }} disabled={updating} - aria-label="Remove item" + aria-label={t("removeItem")} > @@ -213,18 +219,20 @@ export function CartDrawer() { {/* Summary */}
    - Subtotal + {tc("subtotal")} {cart?.display_item_total}
    {cart?.promo_total && parseFloat(cart.promo_total) < 0 && (
    - Discount + {tc("discount")} {cart.display_promo_total}
    )}
    - Shipping - Calculated on checkout + {tc("shipping")} + + {t("shippingCalculatedAtCheckout")} +
    @@ -235,12 +243,12 @@ export function CartDrawer() { href={`${basePath}/checkout/${cart?.id}`} onClick={closeCart} > - Checkout + {t("checkout")}
    diff --git a/src/components/checkout/AddressEditModal.tsx b/src/components/checkout/AddressEditModal.tsx index 7e9472eb..0d09b13b 100644 --- a/src/components/checkout/AddressEditModal.tsx +++ b/src/components/checkout/AddressEditModal.tsx @@ -2,6 +2,7 @@ import type { Country, State } from "@spree/sdk"; import { CircleAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -47,6 +48,8 @@ export function AddressEditModal({ onClose, title, }: AddressEditModalProps) { + const t = useTranslations("address"); + const tc = useTranslations("common"); const [formData, setFormData] = useState( address ? addressToFormData(address) : { ...emptyAddress }, ); @@ -101,14 +104,14 @@ export function AddressEditModal({ await onSave(formDataToAddress(formData), address?.id); onClose(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save address"); + setError(err instanceof Error ? err.message : t("failedToSave")); } finally { setSaving(false); } }; const modalTitle = - title ?? (address?.id ? "Edit Address" : "Add New Address"); + title ?? (address?.id ? t("editAddress") : t("addNewAddress")); return (
    diff --git a/src/components/checkout/AddressFormFields.tsx b/src/components/checkout/AddressFormFields.tsx index a59fdf1d..a6debbf1 100644 --- a/src/components/checkout/AddressFormFields.tsx +++ b/src/components/checkout/AddressFormFields.tsx @@ -1,6 +1,7 @@ "use client"; import type { Country, State } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { @@ -26,12 +27,16 @@ export function AddressFormFields({ onChange, idPrefix, }: AddressFormFieldsProps) { + const t = useTranslations("address"); + const tc = useTranslations("common"); const hasStates = states.length > 0; return (
    - First name + + {t("firstName")} + - Last name + + {t("lastName")} + - - Company (optional) - + {t("company")} - Address + + {t("streetAddress")} + onChange("address1", e.target.value)} - placeholder="Street address" + placeholder={t("streetAddress")} /> - Apartment, suite, etc. (optional) + {t("apartment")} - City + {t("city")} - Country + {t("country")} - Select a country + {t("selectCountry")} {countries.map((country) => ( @@ -120,10 +127,12 @@ export function AddressFormFields({ - State / Province + + {t("stateProvince")} + {loadingStates ? ( - Loading... + {tc("loading")} ) : hasStates ? ( - Select a state + {t("selectState")} {states.map((state) => ( @@ -148,15 +157,13 @@ export function AddressFormFields({ id={`${idPrefix}-state`} value={address.state_name} onChange={(e) => onChange("state_name", e.target.value)} - placeholder="State or province" + placeholder={t("stateOrProvince")} /> )} - - ZIP / Postal code - + {t("zipCode")} - Phone (optional) + {t("phone")} { if (savedAddresses.length === 0) return "new"; @@ -78,7 +81,9 @@ export function AddressSelector({ {/* Saved addresses selection */} {savedAddresses.length > 0 && (
    -

    Select an address

    +

    + {t("selectAnAddress")} +

    {savedAddresses.map((address) => (
    - Edit + {tc("edit")} )}
    @@ -150,7 +155,7 @@ export function AddressSelector({ className="h-4 w-4 text-primary border-gray-300 focus:[outline:1px_solid_black]" /> - Use a different address + {t("useDifferentAddress")}
    diff --git a/src/components/checkout/AddressStep.tsx b/src/components/checkout/AddressStep.tsx index 89733285..572322e4 100644 --- a/src/components/checkout/AddressStep.tsx +++ b/src/components/checkout/AddressStep.tsx @@ -10,6 +10,7 @@ import type { } from "@spree/sdk"; import { InfoIcon } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -54,6 +55,9 @@ export function AddressStep({ onUpdateSavedAddress, processing, }: AddressStepProps) { + const t = useTranslations("checkout"); + const tc = useTranslations("common"); + const ta = useTranslations("address"); const [email, setEmail] = useState(order.email || ""); const [shipAddress, setShipAddress] = useState(() => addressToFormData(order.ship_address), @@ -134,10 +138,10 @@ export function AddressStep({ - Already have an account? + {t("signInPrompt")}
    - Sign in to access your saved - addresses and order history. + {t("signIn")}{" "} + {t("signInDescription")}
    )} @@ -145,10 +149,10 @@ export function AddressStep({ {/* Contact Information */}

    - Contact Information + {t("contactInformation")}

    - Email address + {t("emailAddress")} setEmail(e.target.value)} disabled={isAuthenticated} - placeholder="you@example.com" + placeholder={t("emailPlaceholder")} /> {isAuthenticated && ( - - Using your account email address - + {t("usingAccountEmail")} )}
    @@ -169,7 +171,7 @@ export function AddressStep({ {/* Shipping Address */}

    - Shipping Address + {t("shippingAddress")}

    {isAuthenticated && savedAddresses.length > 0 ? (
    @@ -215,7 +217,7 @@ export function AddressStep({ fetchStates={fetchStates} onSave={handleSaveEditedAddress} onClose={() => setEditingAddress(null)} - title="Edit Address" + title={ta("editAddress")} /> )} diff --git a/src/components/checkout/CouponCode.tsx b/src/components/checkout/CouponCode.tsx index f6506f49..4c4dde41 100644 --- a/src/components/checkout/CouponCode.tsx +++ b/src/components/checkout/CouponCode.tsx @@ -2,6 +2,7 @@ import type { Cart, Order } from "@spree/sdk"; import { CheckCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -17,6 +18,7 @@ interface CouponCodeProps { } export function CouponCode({ order, onApply, onRemove }: CouponCodeProps) { + const t = useTranslations("coupon"); const [code, setCode] = useState(""); const [applying, setApplying] = useState(false); const [removing, setRemoving] = useState(null); @@ -37,7 +39,7 @@ export function CouponCode({ order, onApply, onRemove }: CouponCodeProps) { if (result.success) { setCode(""); } else { - setError(result.error || "Invalid coupon code"); + setError(result.error || t("invalidCode")); } setApplying(false); @@ -49,7 +51,7 @@ export function CouponCode({ order, onApply, onRemove }: CouponCodeProps) { const result = await onRemove(promotionId); if (!result.success) { - setError(result.error || "Failed to remove coupon code"); + setError(result.error || t("failedToRemove")); } setRemoving(null); @@ -78,10 +80,13 @@ export function CouponCode({ order, onApply, onRemove }: CouponCodeProps) { variant="destructive" size="sm" className="absolute top-2 right-2" + aria-label={t("removeCoupon", { + code: promotion.code || promotion.name, + })} onClick={() => handleRemove(promotion.id)} disabled={removing === promotion.id} > - {removing === promotion.id ? "..." : "Remove"} + {removing === promotion.id ? t("removing") : t("remove")} ))} @@ -101,13 +106,13 @@ export function CouponCode({ order, onApply, onRemove }: CouponCodeProps) { setCode(e.target.value); setError(null); }} - placeholder="Gift card or discount code" - aria-label="Gift card or discount code" + placeholder={t("placeholder")} + aria-label={t("placeholder")} aria-invalid={!!error} />
    {error && {error}} diff --git a/src/components/checkout/DeliveryStep.tsx b/src/components/checkout/DeliveryStep.tsx index 338a625b..b1f16eaf 100644 --- a/src/components/checkout/DeliveryStep.tsx +++ b/src/components/checkout/DeliveryStep.tsx @@ -1,6 +1,7 @@ "use client"; import type { Cart, Order, Shipment } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; interface DeliveryStepProps { @@ -20,6 +21,8 @@ export function DeliveryStep({ onBack, processing, }: DeliveryStepProps) { + const t = useTranslations("checkout"); + const tc = useTranslations("common"); // Check if all shipments have a selected rate const allRatesSelected = shipments.every((shipment) => shipment.shipping_rates.some((rate) => rate.selected), @@ -36,11 +39,11 @@ export function DeliveryStep({

    - Shipping Address + {t("shippingAddress")}

    {onBack && ( )}
    @@ -60,7 +63,9 @@ export function DeliveryStep({

    {order.ship_address.country_name}

    {order.ship_address.phone && ( -

    Phone: {order.ship_address.phone}

    +

    + {t("phone", { phone: order.ship_address.phone })} +

    )}
    @@ -69,22 +74,28 @@ export function DeliveryStep({ {/* Shipping Methods */}

    - Shipping Method + {t("shippingMethod")}

    {shipments.length === 0 ? ( -

    Loading shipping options...

    +

    {t("loadingShippingOptions")}

    ) : (
    {shipments.map((shipment, index) => (
    {shipments.length > 1 && (

    - Shipment {index + 1} of {shipments.length} + {t("shipmentOf", { + current: index + 1, + total: shipments.length, + })} {shipment.stock_location?.name && ( {" "} - — Ships from {shipment.stock_location.name} + —{" "} + {t("shipsFrom", { + location: shipment.stock_location.name, + })} )}

    @@ -137,7 +148,7 @@ export function DeliveryStep({ onClick={onBack} disabled={processing} > - Back + {tc("back")} )}
    diff --git a/src/components/checkout/OrderSummary.tsx b/src/components/checkout/OrderSummary.tsx index 713202fc..a7e32c03 100644 --- a/src/components/checkout/OrderSummary.tsx +++ b/src/components/checkout/OrderSummary.tsx @@ -1,6 +1,7 @@ "use client"; import type { Cart, Order } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { ProductImage } from "@/components/ui/product-image"; interface OrderSummaryProps { @@ -8,12 +9,13 @@ interface OrderSummaryProps { } export function OrderSummary({ order }: OrderSummaryProps) { + const tc = useTranslations("common"); const lineItems = order.items || []; return (

    - Order Summary + {tc("orderSummary")}

    {/* Line items */} @@ -56,20 +58,20 @@ export function OrderSummary({ order }: OrderSummaryProps) { {/* Totals */}
    - Subtotal + {tc("subtotal")} {order.display_item_total}
    {parseFloat(order.ship_total) > 0 && (
    - Shipping + {tc("shipping")} {order.display_ship_total}
    )} {parseFloat(order.adjustment_total) !== 0 && (
    - Adjustments + {tc("adjustments")} - Discount + {tc("discount")} {order.display_promo_total}
    )} {parseFloat(order.tax_total) > 0 && (
    - Tax + {tc("tax")} {order.display_tax_total}
    )}
    - Total + {tc("total")} {order.display_total}
    diff --git a/src/components/checkout/PaymentStep.tsx b/src/components/checkout/PaymentStep.tsx index 367c2c7c..f3d287a9 100644 --- a/src/components/checkout/PaymentStep.tsx +++ b/src/components/checkout/PaymentStep.tsx @@ -9,6 +9,7 @@ import type { State, } from "@spree/sdk"; import { CircleAlert, CreditCard, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -56,6 +57,8 @@ export function PaymentStep({ processing, setProcessing, }: PaymentStepProps) { + const t = useTranslations("checkout"); + const tc = useTranslations("common"); // Initialize billing address from order, check if it matches shipping const shipAddressData = addressToFormData(order.ship_address); const billAddressData = addressToFormData(order.bill_address); @@ -121,18 +124,18 @@ export function PaymentStep({ setClientSecret(secret); setPaymentSessionId(result.session.id); } else { - setGatewayError("Failed to initialize payment. Please try again."); + setGatewayError(t("failedToInitPayment")); } } else if (!result.success) { - setGatewayError(result.error || "Failed to create payment session."); + setGatewayError(result.error || t("failedToCreateSession")); } } catch { - setGatewayError("Failed to initialize payment. Please try again."); + setGatewayError(t("failedToInitPayment")); } finally { setLoading(false); } }, - [sessionPaymentMethod, order.id], + [sessionPaymentMethod, order.id, t], ); // On mount: load saved cards (if authenticated), then create initial session — once. @@ -261,7 +264,7 @@ export function PaymentStep({ // 3. Payment succeeded — complete session and order await onPaymentComplete(paymentSessionId); } catch { - setGatewayError("An error occurred during payment. Please try again."); + setGatewayError(t("paymentError")); setProcessing(false); } }; @@ -286,11 +289,11 @@ export function PaymentStep({

    - Shipping Address + {t("shippingAddress")}

    {onBack && ( )}
    @@ -316,7 +319,7 @@ export function PaymentStep({ {/* Billing Address */}

    - Billing Address + {t("billingAddress")}

    @@ -328,7 +331,7 @@ export function PaymentStep({ } /> - Same as shipping address + {t("sameAsShipping")}
    @@ -348,7 +351,7 @@ export function PaymentStep({ {/* Payment Method */}

    - Payment Method + {t("paymentMethod")}

    {/* Saved Cards Selector */} @@ -379,15 +382,16 @@ export function PaymentStep({ />
    - {getCardLabel(card.cc_type)} ending in {card.last_digits} + {getCardLabel(card.cc_type)} {t("endingIn")}{" "} + {card.last_digits} - Exp {String(card.month).padStart(2, "0")}/{card.year} + {t("exp")} {String(card.month).padStart(2, "0")}/{card.year}
    {card.default && ( - Default + {t("default")} )} @@ -410,7 +414,7 @@ export function PaymentStep({ /> - Add new payment method + {t("addNewPaymentMethod")}
    @@ -420,7 +424,7 @@ export function PaymentStep({
    - Loading payment form... + {t("loadingPaymentForm")}
    )} @@ -446,9 +450,7 @@ export function PaymentStep({ className="w-12 h-12 text-gray-400 mx-auto mb-4" strokeWidth={1.5} /> -

    - No payment methods available for this order. -

    +

    {t("noPaymentMethods")}

    )}
    @@ -463,7 +465,7 @@ export function PaymentStep({ onClick={onBack} disabled={processing} > - Back + {tc("back")} )}
    diff --git a/src/components/layout/CountrySwitcher.tsx b/src/components/layout/CountrySwitcher.tsx index d06c6f22..215aebcb 100644 --- a/src/components/layout/CountrySwitcher.tsx +++ b/src/components/layout/CountrySwitcher.tsx @@ -2,6 +2,7 @@ import { Check, ChevronDown } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -30,6 +31,8 @@ function countryToFlag(countryCode: string): string { export function CountrySwitcher() { const { country, currency, countries, setCountry, loading } = useStore(); + const tc = useTranslations("common"); + const currentLocale = useLocale(); const { cart, refreshCart } = useCart(); const router = useRouter(); const pathname = usePathname(); @@ -56,7 +59,13 @@ export function CountrySwitcher() { setStoreCookies(entry.iso.toLowerCase(), newLocale); setCountry(entry.iso.toLowerCase()); - router.push(newPath); + // Hard navigation when locale changes so RootLayout re-renders + // with new messages from NextIntlClientProvider + if (newLocale !== currentLocale) { + window.location.href = newPath; + } else { + router.push(newPath); + } }; if (loading) { @@ -80,7 +89,7 @@ export function CountrySwitcher() { - Select Country + {tc("selectCountry")} {countries.map((c) => { const isSelected = c.iso.toLowerCase() === country.toLowerCase(); return ( diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index a5f4a64b..9fbb3a50 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -1,10 +1,12 @@ "use client"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { useStore } from "@/contexts/StoreContext"; export function Footer() { - const { storeName, storeDescription } = useStore(); + const { storeName } = useStore(); + const t = useTranslations("footer"); return (
    @@ -12,19 +14,21 @@ export function Footer() { {/* Brand */}
    {storeName} -

    {storeDescription}

    +

    {t("description")}

    {/* Links */}
    -

    Shop

    +

    + {t("shop")} +

    • - All Products + {t("allProducts")}
    • @@ -32,7 +36,7 @@ export function Footer() { href="/products" className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors" > - Categories + {t("categories")}
    @@ -40,14 +44,16 @@ export function Footer() { {/* Account */}
    -

    Account

    +

    + {t("account")} +

    • - My Account + {t("myAccount")}
    • @@ -55,7 +61,7 @@ export function Footer() { href="/account/orders" className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors" > - Order History + {t("orderHistory")}
    • @@ -63,7 +69,7 @@ export function Footer() { href="/cart" className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors" > - Cart + {t("cart")}
    @@ -71,10 +77,7 @@ export function Footer() {
    -

    - © {new Date().getFullYear()} {storeName}. Powered by Spree - Commerce. -

    +

    {t("copyright", { year: new Date().getFullYear(), storeName })}

    diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 70ae4b93..bbe95fce 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -5,6 +5,7 @@ import { ShoppingBag, User } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { SearchBar } from "@/components/search/SearchBar"; import { Button } from "@/components/ui/button"; import { useCart } from "@/contexts/CartContext"; @@ -21,6 +22,7 @@ export function Header({ rootCategories }: HeaderProps) { const { storeName } = useStore(); const pathname = usePathname(); const basePath = extractBasePath(pathname); + const t = useTranslations("header"); return (
    @@ -72,7 +74,7 @@ export function Header({ rootCategories }: HeaderProps) { variant="ghost" size="icon-lg" onClick={openCart} - aria-label="Open cart" + aria-label={t("openCart")} className="relative" > @@ -85,7 +87,7 @@ export function Header({ rootCategories }: HeaderProps) { {/* Account */} diff --git a/src/components/navigation/Breadcrumbs.tsx b/src/components/navigation/Breadcrumbs.tsx index b2a351bb..6587c176 100644 --- a/src/components/navigation/Breadcrumbs.tsx +++ b/src/components/navigation/Breadcrumbs.tsx @@ -1,6 +1,7 @@ import type { Category } from "@spree/sdk"; import { ChevronRight } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; interface BreadcrumbsProps { category: Category; @@ -8,8 +9,9 @@ interface BreadcrumbsProps { } export function Breadcrumbs({ category, basePath }: BreadcrumbsProps) { + const t = useTranslations("navigation"); // Build breadcrumb items from ancestors + current category - const items = [{ name: "Home", href: basePath }]; + const items = [{ name: t("home"), href: basePath }]; // Add ancestors (they come from the API in order from root to parent) if (category.ancestors && category.ancestors.length > 0) { @@ -25,7 +27,7 @@ export function Breadcrumbs({ category, basePath }: BreadcrumbsProps) { items.push({ name: category.name, href: "" }); return ( -
    @@ -98,7 +100,7 @@ export const ProductCard = memo(function ProductCard({
    {!product.purchasable && ( - Out of Stock + {t("outOfStock")} )}
    diff --git a/src/components/products/ProductCarousel.tsx b/src/components/products/ProductCarousel.tsx index eb1b6ec4..894f48de 100644 --- a/src/components/products/ProductCarousel.tsx +++ b/src/components/products/ProductCarousel.tsx @@ -8,6 +8,7 @@ import { Swiper as SwiperComponent, SwiperSlide } from "swiper/react"; import "swiper/css"; import "swiper/css/navigation"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslations } from "next-intl"; import { ProductCard } from "@/components/products/ProductCard"; import { ProductCardSkeleton } from "@/components/products/ProductCardSkeleton"; import { useCarouselProducts } from "@/hooks/useCarouselProducts"; @@ -26,6 +27,7 @@ export function ProductCarousel({ limit = 8, basePath, }: ProductCarouselProps): ReactElement { + const t = useTranslations("products"); const { products, loading, error } = useCarouselProducts({ categoryId, limit, @@ -79,7 +81,7 @@ export function ProductCarousel({
    diff --git a/src/components/products/VariantPicker.tsx b/src/components/products/VariantPicker.tsx index 7eb563e8..eeb173a7 100644 --- a/src/components/products/VariantPicker.tsx +++ b/src/components/products/VariantPicker.tsx @@ -1,6 +1,7 @@ "use client"; import type { OptionType, Variant } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { isColorOption, resolveColor } from "@/lib/utils/color-map"; @@ -18,6 +19,7 @@ export function VariantPicker({ selectedVariant, onVariantChange, }: VariantPickerProps) { + const t = useTranslations("products"); const optionValuesMap = useMemo(() => { const map: Record> = {}; @@ -216,7 +218,7 @@ export function VariantPicker({ {optionValue?.presentation || value} {!isPurchasable && isAvailable && ( - (Out of stock) + {t("outOfStockVariant")} )} diff --git a/src/components/products/__tests__/ProductCard.test.tsx b/src/components/products/__tests__/ProductCard.test.tsx index dc2b2dc8..6cd60b31 100644 --- a/src/components/products/__tests__/ProductCard.test.tsx +++ b/src/components/products/__tests__/ProductCard.test.tsx @@ -3,6 +3,10 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProductCard } from "@/components/products/ProductCard"; +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + vi.mock("@/contexts/StoreContext", () => ({ useStore: () => ({ currency: "USD", locale: "en", loading: false }), })); @@ -98,7 +102,7 @@ describe("ProductCard", () => { it("shows Sale badge when on sale", () => { render(); - expect(screen.getByText("Sale")).toBeInTheDocument(); + expect(screen.getByText("sale")).toBeInTheDocument(); }); it("shows strikethrough price when on sale", () => { @@ -113,13 +117,13 @@ describe("ProductCard", () => { it("does not show Sale badge for regular price products", () => { render(); - expect(screen.queryByText("Sale")).not.toBeInTheDocument(); + expect(screen.queryByText("sale")).not.toBeInTheDocument(); }); it("shows Out of Stock for non-purchasable products", () => { render(); - expect(screen.getByText("Out of Stock")).toBeInTheDocument(); + expect(screen.getByText("outOfStock")).toBeInTheDocument(); }); it("renders image when thumbnail_url is provided", () => { diff --git a/src/components/products/filters/AvailabilityDropdownContent.tsx b/src/components/products/filters/AvailabilityDropdownContent.tsx index 3d97c50d..08e39368 100644 --- a/src/components/products/filters/AvailabilityDropdownContent.tsx +++ b/src/components/products/filters/AvailabilityDropdownContent.tsx @@ -1,9 +1,12 @@ +"use client"; + import type { AvailabilityFilter } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { DropdownMenuRadioGroup, DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu"; -import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import { getAvailabilityLabel } from "@/lib/utils/filters"; import { type AvailabilityStatus, isAvailabilityStatus } from "@/types/filters"; interface AvailabilityDropdownContentProps { @@ -17,6 +20,8 @@ export function AvailabilityDropdownContent({ selected, onChange, }: AvailabilityDropdownContentProps) { + const t = useTranslations("products"); + return ( - Any availability + + {t("anyAvailability")} + {filter.options.map((option) => ( e.preventDefault()} > - - {AVAILABILITY_LABELS[option.id] || option.id} - + {getAvailabilityLabel(option.id, t)} ({option.count}) diff --git a/src/components/products/filters/FilterChips.tsx b/src/components/products/filters/FilterChips.tsx index fe56ef5a..2cd393d9 100644 --- a/src/components/products/filters/FilterChips.tsx +++ b/src/components/products/filters/FilterChips.tsx @@ -2,8 +2,9 @@ import type { OptionFilter, ProductFiltersResponse } from "@spree/sdk"; import { X } from "lucide-react"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import { getAvailabilityLabel } from "@/lib/utils/filters"; import { findMatchingBucket, type PriceBucket, @@ -29,6 +30,7 @@ export function FilterChips({ onRemoveAvailability, onClearAll, }: FilterChipsProps) { + const t = useTranslations("products"); const chips: { key: string; label: string; onRemove: () => void }[] = []; if (filtersData) { @@ -63,7 +65,7 @@ export function FilterChips({ ); chips.push({ key: "price", - label: `Price: ${matchingBucket?.label || "Custom"}`, + label: `${t("price")}: ${matchingBucket?.label || t("customPrice")}`, onRemove: onRemovePrice, }); } @@ -71,7 +73,7 @@ export function FilterChips({ if (activeFilters.availability) { chips.push({ key: "availability", - label: `${AVAILABILITY_LABELS[activeFilters.availability] || activeFilters.availability}`, + label: getAvailabilityLabel(activeFilters.availability, t), onRemove: onRemoveAvailability, }); } @@ -89,14 +91,14 @@ export function FilterChips({ ))}
    ); diff --git a/src/components/products/filters/MobileFilterDrawer.tsx b/src/components/products/filters/MobileFilterDrawer.tsx index b6f84140..247b3cc9 100644 --- a/src/components/products/filters/MobileFilterDrawer.tsx +++ b/src/components/products/filters/MobileFilterDrawer.tsx @@ -6,11 +6,15 @@ import type { ProductFiltersResponse, } from "@spree/sdk"; import { Check, X } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { isColorOption, resolveColor } from "@/lib/utils/color-map"; -import { AVAILABILITY_LABELS, getActiveFilterCount } from "@/lib/utils/filters"; +import { + getActiveFilterCount, + getAvailabilityLabel, +} from "@/lib/utils/filters"; import type { PriceBucket } from "@/lib/utils/price-buckets"; import { findMatchingBucket } from "@/lib/utils/price-buckets"; import { @@ -36,6 +40,7 @@ export function MobileFilterDrawer({ priceBuckets, onApply, }: MobileFilterDrawerProps) { + const t = useTranslations("products"); const [stagedFilters, setStagedFilters] = useState(activeFilters); @@ -93,18 +98,18 @@ export function MobileFilterDrawer({ showCloseButton={false} aria-describedby={undefined} > - Filters + {t("filters")}
    -

    Filters

    +

    {t("filters")}

    @@ -147,11 +152,11 @@ export function MobileFilterDrawer({
    {stagedCount > 0 && ( )}
    @@ -247,6 +252,8 @@ function MobilePriceSection({ activeFilters: ActiveFilters; onPriceChange: (min?: number, max?: number) => void; }) { + const t = useTranslations("products"); + if (priceBuckets.length === 0) return null; const selectedBucket = findMatchingBucket( @@ -258,7 +265,7 @@ function MobilePriceSection({ return (

    - Price + {t("price")}

    {priceBuckets.map((bucket) => { @@ -302,10 +309,12 @@ function MobileAvailabilitySection({ selected?: AvailabilityStatus; onChange: (value?: AvailabilityStatus) => void; }) { + const t = useTranslations("products"); + return (

    - Availability + {t("availability")}

    {filter.options.map((option) => { @@ -329,7 +338,7 @@ function MobileAvailabilitySection({ }`} > - {AVAILABILITY_LABELS[option.id] || option.id} + {getAvailabilityLabel(option.id, t)} ({option.count}) {isSelected && ( diff --git a/src/components/products/filters/ProductFilters.tsx b/src/components/products/filters/ProductFilters.tsx index b67edad8..9c648125 100644 --- a/src/components/products/filters/ProductFilters.tsx +++ b/src/components/products/filters/ProductFilters.tsx @@ -7,6 +7,7 @@ import type { ProductFiltersResponse, } from "@spree/sdk"; import { SlidersHorizontal } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; import type { JSX } from "react"; import { memo, useCallback, useMemo, useState } from "react"; import { AvailabilityDropdownContent } from "@/components/products/filters/AvailabilityDropdownContent"; @@ -35,6 +36,8 @@ export const FilterBar = memo(function FilterBar({ totalCount, onFilterChange, }: FilterBarProps): JSX.Element | null { + const t = useTranslations("products"); + const locale = useLocale(); const [openDropdownId, setOpenDropdownId] = useState(null); const [showMobileDrawer, setShowMobileDrawer] = useState(false); @@ -98,8 +101,9 @@ export const FilterBar = memo(function FilterBar({ priceFilter.min, priceFilter.max, priceFilter.currency, + { t, locale }, ); - }, [filtersData]); + }, [filtersData, t, locale]); const optionFilters = useMemo(() => { if (!filtersData) return []; @@ -176,7 +180,7 @@ export const FilterBar = memo(function FilterBar({ {hasPriceFilter && ( toggleDropdown("price")} @@ -192,7 +196,7 @@ export const FilterBar = memo(function FilterBar({ {availabilityFilter && ( toggleDropdown("availability")} @@ -209,10 +213,10 @@ export const FilterBar = memo(function FilterBar({
    - {totalCount} {totalCount === 1 ? "product" : "products"} + {t("productCount", { count: totalCount })} toggleDropdown("sort")} onClose={closeDropdown} @@ -238,7 +242,7 @@ export const FilterBar = memo(function FilterBar({ }`} > - Filters + {t("filters")} {hasActiveFilters && ( {totalActiveFilters} @@ -248,7 +252,7 @@ export const FilterBar = memo(function FilterBar({
    toggleDropdown("sort-mobile")} onClose={closeDropdown} diff --git a/src/components/products/filters/SortDropdownContent.tsx b/src/components/products/filters/SortDropdownContent.tsx index 367d1c40..47c56330 100644 --- a/src/components/products/filters/SortDropdownContent.tsx +++ b/src/components/products/filters/SortDropdownContent.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslations } from "next-intl"; import { DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -15,11 +18,13 @@ export function SortDropdownContent({ activeSortBy, onSortChange, }: SortDropdownContentProps) { + const t = useTranslations("products"); + return ( {sortOptions.map((option) => ( - {getSortLabel(option.id)} + {getSortLabel(option.id, t)} ))} diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 72a6df1c..823e6449 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -3,6 +3,7 @@ import type { Product } from "@spree/sdk"; import { Search } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { InputGroup, @@ -21,6 +22,7 @@ interface SearchBarProps { export function SearchBar({ basePath }: SearchBarProps) { const router = useRouter(); const { currency } = useStore(); + const t = useTranslations("products"); const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const [isOpen, setIsOpen] = useState(false); @@ -157,7 +159,7 @@ export function SearchBar({ basePath }: SearchBarProps) { }} onFocus={() => setIsOpen(true)} onKeyDown={handleKeyDown} - placeholder="Search..." + placeholder={t("search")} role="combobox" aria-expanded={showSuggestions} aria-controls="search-suggestions" @@ -178,7 +180,7 @@ export function SearchBar({ basePath }: SearchBarProps) {
    {loading ? (
    - Searching... + {t("searching")}
    ) : suggestions.length > 0 ? (
      @@ -235,14 +237,14 @@ export function SearchBar({ basePath }: SearchBarProps) { }} className="w-full p-3 text-sm text-primary hover:bg-gray-50 text-center font-medium" > - View all results for “{query}” + {t("viewAllResultsFor", { query })} )}
    ) : query.length >= 2 ? (
    - No products found + {t("noProductsFound")}
    ) : null}
    diff --git a/src/contexts/StoreContext.tsx b/src/contexts/StoreContext.tsx index cd0f1fb9..c1b946e8 100644 --- a/src/contexts/StoreContext.tsx +++ b/src/contexts/StoreContext.tsx @@ -11,6 +11,7 @@ import { useRef, useState, } from "react"; +import { getStoreName } from "@/lib/seo"; import { setStoreCookies } from "@/lib/utils/cookies"; import { getPathWithoutPrefix } from "@/lib/utils/path"; @@ -21,14 +22,10 @@ export interface CountryWithMarket extends Country { marketId: string | null; } -const storeName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store"; -const storeDescription = - process.env.NEXT_PUBLIC_STORE_DESCRIPTION || - "A modern e-commerce storefront powered by Spree Commerce and Next.js."; +const storeName = getStoreName(); interface StoreContextValue { storeName: string; - storeDescription: string; country: string; locale: string; currency: string; @@ -183,7 +180,6 @@ export function StoreProvider({ const value = useMemo( () => ({ storeName, - storeDescription, country, locale, currency, diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 00000000..00178cb6 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,30 @@ +import { cookies } from "next/headers"; +import { getRequestConfig } from "next-intl/server"; + +const supportedLocales = ["en", "de", "pl"]; + +export default getRequestConfig(async ({ requestLocale }) => { + // Prefer the route-derived locale (from the [locale] path segment), + // fall back to the spree_locale cookie, then default to "en". + let locale = await requestLocale; + + if (!locale || !supportedLocales.includes(locale)) { + const cookieStore = await cookies(); + locale = cookieStore.get("spree_locale")?.value; + } + + if (!locale || !supportedLocales.includes(locale)) { + locale = "en"; + } + + let messages: IntlMessages; + try { + messages = (await import(`../../messages/${locale}.json`)).default; + } catch { + // Fallback to English if locale file doesn't exist + locale = "en"; + messages = (await import("../../messages/en.json")).default; + } + + return { locale, messages }; +}); diff --git a/src/lib/utils/filters.ts b/src/lib/utils/filters.ts index 33902290..7f265252 100644 --- a/src/lib/utils/filters.ts +++ b/src/lib/utils/filters.ts @@ -21,19 +21,20 @@ export function getActiveFilterCount(filters: ActiveFilters): number { ); } -const SORT_LABELS_CANONICAL: Record = { - manual: "Relevance", - best_selling: "Best Selling", - price: "Price: Low to High", - "-price": "Price: High to Low", - "-available_on": "Newest", - available_on: "Oldest", - name: "Name (A-Z)", - "-name": "Name (Z-A)", +/** Maps sort API keys to translation message keys in the "products" namespace. */ +const SORT_KEY_TO_MESSAGE: Record = { + manual: "manual", + best_selling: "bestSelling", + price: "priceLowHigh", + "-price": "priceHighLow", + "-available_on": "newest", + available_on: "oldest", + name: "nameAZ", + "-name": "nameZA", }; export function normalizeSortKey(key: string): string { - if (key in SORT_LABELS_CANONICAL) return key; + if (key in SORT_KEY_TO_MESSAGE) return key; const match = key.match(/^(\w+)\s+(asc|desc)$/); if (!match) return key; const [, field, direction] = match; @@ -43,11 +44,33 @@ export function normalizeSortKey(key: string): string { return needsNegation ? `-${field}` : field; } -export function getSortLabel(key: string): string { - return SORT_LABELS_CANONICAL[normalizeSortKey(key)] || key; +/** + * Get a translated sort label. + * Pass a `t` function from `useTranslations("products")`. + * Falls back to the raw key if no translator is provided. + */ +export function getSortLabel(key: string, t?: (key: string) => string): string { + const normalized = normalizeSortKey(key); + const messageKey = SORT_KEY_TO_MESSAGE[normalized]; + if (messageKey && t) return t(messageKey); + return key; } -export const AVAILABILITY_LABELS: Record = { - in_stock: "In Stock", - out_of_stock: "Out of Stock", +/** Maps availability API values to translation message keys in the "products" namespace. */ +const AVAILABILITY_KEY_TO_MESSAGE: Record = { + in_stock: "inStock", + out_of_stock: "outOfStock", }; + +/** + * Get a translated availability label. + * Pass a `t` function from `useTranslations("products")`. + */ +export function getAvailabilityLabel( + id: string, + t?: (key: string) => string, +): string { + const messageKey = AVAILABILITY_KEY_TO_MESSAGE[id]; + if (messageKey && t) return t(messageKey); + return id; +} diff --git a/src/lib/utils/price-buckets.ts b/src/lib/utils/price-buckets.ts index 648cae3f..4ddb407f 100644 --- a/src/lib/utils/price-buckets.ts +++ b/src/lib/utils/price-buckets.ts @@ -7,9 +7,13 @@ export interface PriceBucket { const THRESHOLDS = [50, 100, 200]; -function formatCurrency(amount: number, currency: string): string { +function formatCurrency( + amount: number, + currency: string, + locale?: string, +): string { try { - return new Intl.NumberFormat("en", { + return new Intl.NumberFormat(locale || "en", { style: "currency", currency, minimumFractionDigits: 0, @@ -20,26 +24,41 @@ function formatCurrency(amount: number, currency: string): string { } } +/** + * Optional translation helpers for bucket labels. + * Pass `t` from `useTranslations("products")` and a locale string + * to get labels like "Poniżej $50" instead of "Under $50". + */ +interface BucketLabelOptions { + t?: (key: string, values?: Record) => string; + locale?: string; +} + export function generatePriceBuckets( filterMin: number, filterMax: number, currency: string, + options?: BucketLabelOptions, ): PriceBucket[] { + const { t, locale } = options || {}; const buckets: PriceBucket[] = []; if (filterMin < THRESHOLDS[0]) { + const price = formatCurrency(THRESHOLDS[0], currency, locale); buckets.push({ id: `under-${THRESHOLDS[0]}`, - label: `Under ${formatCurrency(THRESHOLDS[0], currency)}`, + label: t ? t("priceUnder", { price }) : `Under ${price}`, max: THRESHOLDS[0], }); } for (let i = 0; i < THRESHOLDS.length - 1; i++) { if (filterMax > THRESHOLDS[i] && filterMin < THRESHOLDS[i + 1]) { + const min = formatCurrency(THRESHOLDS[i], currency, locale); + const max = formatCurrency(THRESHOLDS[i + 1], currency, locale); buckets.push({ id: `${THRESHOLDS[i]}-${THRESHOLDS[i + 1]}`, - label: `${formatCurrency(THRESHOLDS[i], currency)} - ${formatCurrency(THRESHOLDS[i + 1], currency)}`, + label: t ? t("priceRangeBucket", { min, max }) : `${min} - ${max}`, min: THRESHOLDS[i], max: THRESHOLDS[i + 1], }); @@ -48,9 +67,10 @@ export function generatePriceBuckets( const lastThreshold = THRESHOLDS[THRESHOLDS.length - 1]; if (filterMax > lastThreshold) { + const price = formatCurrency(lastThreshold, currency, locale); buckets.push({ id: `${lastThreshold}-plus`, - label: `${formatCurrency(lastThreshold, currency)}+`, + label: t ? t("priceAbove", { price }) : `${price}+`, min: lastThreshold, }); } diff --git a/src/types/next-intl.d.ts b/src/types/next-intl.d.ts new file mode 100644 index 00000000..9490a9ea --- /dev/null +++ b/src/types/next-intl.d.ts @@ -0,0 +1,7 @@ +import type messages from "../../messages/en.json"; + +type Messages = typeof messages; + +declare global { + interface IntlMessages extends Messages {} +} From 5a95ed76cdd8ba3d3958289bb651b06c7c257af9 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 12 Mar 2026 13:22:05 +0100 Subject: [PATCH 2/8] Fix remaining i18n hardcoded strings and accessibility issues Replace hardcoded English strings with translation keys across checkout, account, and storefront pages. Add fieldset/legend for radio groups in AddressSelector, localize aria-labels, and use getLocale() in root layout for consistent locale resolution. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 32 +++++--- messages/en.json | 32 +++++--- messages/pl.json | 32 +++++--- .../(checkout)/checkout/[id]/page.tsx | 75 ++++++++++--------- .../[country]/[locale]/(checkout)/layout.tsx | 4 +- .../(checkout)/order-placed/[id]/page.tsx | 6 +- .../(storefront)/account/addresses/page.tsx | 9 ++- .../account/credit-cards/page.tsx | 8 +- .../(storefront)/account/gift-cards/page.tsx | 24 +++--- .../(storefront)/account/orders/[id]/page.tsx | 54 ++++++------- .../(storefront)/account/orders/page.tsx | 55 ++------------ .../(storefront)/account/profile/page.tsx | 4 +- .../(storefront)/account/register/page.tsx | 6 +- .../[locale]/(storefront)/cart/page.tsx | 2 +- .../[country]/[locale]/(storefront)/page.tsx | 6 +- src/app/layout.tsx | 6 +- src/components/checkout/AddressSelector.tsx | 8 +- src/components/navigation/Breadcrumbs.tsx | 2 + src/components/products/ProductCarousel.tsx | 2 +- src/components/search/SearchBar.tsx | 2 +- src/i18n/request.ts | 13 ++-- src/lib/utils/order-status.ts | 53 +++++++++++++ 22 files changed, 251 insertions(+), 184 deletions(-) create mode 100644 src/lib/utils/order-status.ts diff --git a/messages/de.json b/messages/de.json index 4e7e2886..5c8af28f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -37,7 +37,7 @@ "copyright": "© {year} {storeName}. Unterstützt durch Spree Commerce." }, "home": { - "welcome": "Willkommen bei Spree Store", + "welcome": "Willkommen bei {storeName}", "heroDescription": "Entdecken Sie großartige Produkte mit unserem modernen E-Commerce-Erlebnis powered by Spree Commerce.", "shopNow": "Jetzt einkaufen", "browseCategories": "Kategorien durchsuchen", @@ -64,7 +64,8 @@ "shoppingCart": "Warenkorb", "proceedToCheckout": "Zur Kasse gehen", "itemCount": "{count, plural, one {# Artikel} other {# Artikel}}", - "shippingCalculatedAtCheckout": "Wird an der Kasse berechnet" + "shippingCalculatedAtCheckout": "Wird an der Kasse berechnet", + "removeItemLabel": "{name} entfernen" }, "products": { "allProducts": "Alle Produkte", @@ -189,7 +190,8 @@ "failedToCompleteOrder": "Bestellung konnte nicht abgeschlossen werden", "stripeNotLoaded": "Stripe wurde noch nicht geladen", "paymentProcessingError": "Bei der Zahlung ist ein Fehler aufgetreten.", - "failedToCompletePaymentSession": "Zahlungssitzung konnte nicht abgeschlossen werden" + "failedToCompletePaymentSession": "Zahlungssitzung konnte nicht abgeschlossen werden", + "noOrder": "Keine Bestellung" }, "address": { "firstName": "Vorname", @@ -211,7 +213,10 @@ "editAddress": "Adresse bearbeiten", "addNewAddress": "Neue Adresse hinzufügen", "saveAddress": "Adresse speichern", - "failedToSave": "Adresse konnte nicht gespeichert werden" + "failedToSave": "Adresse konnte nicht gespeichert werden", + "deleteAddressTitle": "Adresse löschen?", + "delete": "Löschen", + "deleting": "Wird gelöscht..." }, "account": { "myAccount": "Mein Konto", @@ -254,7 +259,10 @@ "passwordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein", "registrationFailed": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut.", "alreadyHaveAccount": "Haben Sie bereits ein Konto?", - "signIn": "Anmelden" + "signIn": "Anmelden", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + "firstName": "Vorname", + "lastName": "Nachname" }, "orders": { "orderHistory": "Bestellverlauf", @@ -300,7 +308,9 @@ "creditOwed": "Guthaben geschuldet", "failed": "Fehlgeschlagen", "void": "Storniert", - "notAvailable": "k. A." + "notAvailable": "k. A.", + "orderTitle": "Bestellung #{number}", + "shipmentCanceledRefund": "Sendung storniert — eine Rückerstattung wurde veranlasst." }, "orderPlaced": { "thanksForOrder": "Vielen Dank für Ihre Bestellung, {name}!", @@ -312,7 +322,8 @@ "shippingAddress": "Lieferadresse", "billingAddress": "Rechnungsadresse", "orderNotFound": "Bestellung nicht gefunden", - "failedToLoad": "Bestelldetails konnten nicht geladen werden." + "failedToLoad": "Bestelldetails konnten nicht geladen werden.", + "qty": "Menge: {quantity}" }, "coupon": { "placeholder": "Geschenkkarte oder Rabattcode", @@ -342,7 +353,8 @@ "accountInformation": "Kontoinformationen", "accountId": "Konto-ID", "loadingProfile": "Profil wird geladen...", - "failedToUpdate": "Profil konnte nicht aktualisiert werden" + "failedToUpdate": "Profil konnte nicht aktualisiert werden", + "email": "E-Mail" }, "creditCards": { "paymentMethods": "Zahlungsmethoden", @@ -353,7 +365,9 @@ "removing": "Wird entfernt...", "secureInfo": "Ihre Zahlungsinformationen werden sicher gespeichert.", "deleteConfirm": "Sind Sie sicher, dass Sie diese Karte entfernen möchten?", - "cardMaskedLabel": "{label} endet auf {digits}, gültig bis {month}/{year}" + "cardMaskedLabel": "{label} endet auf {digits}, gültig bis {month}/{year}", + "removePaymentMethodTitle": "Zahlungsmethode entfernen?", + "endingIn": "endet auf" }, "giftCards": { "giftCards": "Geschenkkarten", diff --git a/messages/en.json b/messages/en.json index ec05253f..6fad8f8e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -37,7 +37,7 @@ "copyright": "© {year} {storeName}. Powered by Spree Commerce." }, "home": { - "welcome": "Welcome to Spree Store", + "welcome": "Welcome to {storeName}", "heroDescription": "Discover amazing products with our modern e-commerce experience powered by Spree Commerce.", "shopNow": "Shop Now", "browseCategories": "Browse Categories", @@ -64,7 +64,8 @@ "shoppingCart": "Shopping Cart", "proceedToCheckout": "Proceed to Checkout", "itemCount": "{count, plural, one {# item} other {# items}}", - "shippingCalculatedAtCheckout": "Calculated at checkout" + "shippingCalculatedAtCheckout": "Calculated at checkout", + "removeItemLabel": "Remove {name}" }, "products": { "allProducts": "All Products", @@ -189,7 +190,8 @@ "failedToCompleteOrder": "Failed to complete order", "stripeNotLoaded": "Stripe has not loaded yet", "paymentProcessingError": "An error occurred during payment.", - "failedToCompletePaymentSession": "Failed to complete payment session" + "failedToCompletePaymentSession": "Failed to complete payment session", + "noOrder": "No order" }, "address": { "firstName": "First name", @@ -211,7 +213,10 @@ "editAddress": "Edit Address", "addNewAddress": "Add New Address", "saveAddress": "Save Address", - "failedToSave": "Failed to save address" + "failedToSave": "Failed to save address", + "deleteAddressTitle": "Delete address?", + "delete": "Delete", + "deleting": "Deleting..." }, "account": { "myAccount": "My Account", @@ -254,7 +259,10 @@ "passwordTooShort": "Password must be at least 6 characters", "registrationFailed": "Registration failed. Please try again.", "alreadyHaveAccount": "Already have an account?", - "signIn": "Sign in" + "signIn": "Sign in", + "unexpectedError": "An unexpected error occurred. Please try again.", + "firstName": "First name", + "lastName": "Last name" }, "orders": { "orderHistory": "Order History", @@ -300,7 +308,9 @@ "creditOwed": "Credit Owed", "failed": "Failed", "void": "Void", - "notAvailable": "N/A" + "notAvailable": "N/A", + "orderTitle": "Order #{number}", + "shipmentCanceledRefund": "Shipment canceled — a refund has been issued." }, "orderPlaced": { "thanksForOrder": "Thanks for your order, {name}!", @@ -312,7 +322,8 @@ "shippingAddress": "Shipping Address", "billingAddress": "Billing Address", "orderNotFound": "Order not found", - "failedToLoad": "Failed to load order details." + "failedToLoad": "Failed to load order details.", + "qty": "Qty: {quantity}" }, "coupon": { "placeholder": "Gift card or discount code", @@ -342,7 +353,8 @@ "accountInformation": "Account Information", "accountId": "Account ID", "loadingProfile": "Loading profile...", - "failedToUpdate": "Failed to update profile" + "failedToUpdate": "Failed to update profile", + "email": "Email" }, "creditCards": { "paymentMethods": "Payment Methods", @@ -353,7 +365,9 @@ "removing": "Removing...", "secureInfo": "Your payment information is securely stored.", "deleteConfirm": "Are you sure you want to remove this card?", - "cardMaskedLabel": "{label} ending in {digits}, expires {month}/{year}" + "cardMaskedLabel": "{label} ending in {digits}, expires {month}/{year}", + "removePaymentMethodTitle": "Remove payment method?", + "endingIn": "ending in" }, "giftCards": { "giftCards": "Gift Cards", diff --git a/messages/pl.json b/messages/pl.json index 424ff17e..f907b673 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -37,7 +37,7 @@ "copyright": "© {year} {storeName}. Zasilany przez Spree Commerce." }, "home": { - "welcome": "Witaj w Sklepie Spree", + "welcome": "Witaj w {storeName}", "heroDescription": "Odkryj niesamowite produkty dzięki nowoczesnemu sklepowi internetowemu opartemu na Spree Commerce.", "shopNow": "Kup teraz", "browseCategories": "Przeglądaj kategorie", @@ -64,7 +64,8 @@ "shoppingCart": "Koszyk zakupowy", "proceedToCheckout": "Przejdź do kasy", "itemCount": "{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktów}}", - "shippingCalculatedAtCheckout": "Obliczona przy kasie" + "shippingCalculatedAtCheckout": "Obliczona przy kasie", + "removeItemLabel": "Usuń {name}" }, "products": { "allProducts": "Wszystkie produkty", @@ -189,7 +190,8 @@ "failedToCompleteOrder": "Nie udało się złożyć zamówienia", "stripeNotLoaded": "Stripe nie został jeszcze załadowany", "paymentProcessingError": "Wystąpił błąd podczas przetwarzania płatności.", - "failedToCompletePaymentSession": "Nie udało się zakończyć sesji płatności" + "failedToCompletePaymentSession": "Nie udało się zakończyć sesji płatności", + "noOrder": "Brak zamówienia" }, "address": { "firstName": "Imię", @@ -211,7 +213,10 @@ "editAddress": "Edytuj adres", "addNewAddress": "Dodaj nowy adres", "saveAddress": "Zapisz adres", - "failedToSave": "Nie udało się zapisać adresu" + "failedToSave": "Nie udało się zapisać adresu", + "deleteAddressTitle": "Usunąć adres?", + "delete": "Usuń", + "deleting": "Usuwanie..." }, "account": { "myAccount": "Moje konto", @@ -254,7 +259,10 @@ "passwordTooShort": "Hasło musi mieć co najmniej 6 znaków", "registrationFailed": "Rejestracja nie powiodła się. Spróbuj ponownie.", "alreadyHaveAccount": "Masz już konto?", - "signIn": "Zaloguj się" + "signIn": "Zaloguj się", + "unexpectedError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.", + "firstName": "Imię", + "lastName": "Nazwisko" }, "orders": { "orderHistory": "Historia zamówień", @@ -300,7 +308,9 @@ "creditOwed": "Należny kredyt", "failed": "Nieudane", "void": "Unieważnione", - "notAvailable": "Brak" + "notAvailable": "Brak", + "orderTitle": "Zamówienie #{number}", + "shipmentCanceledRefund": "Przesyłka anulowana — zwrot został wystawiony." }, "orderPlaced": { "thanksForOrder": "Dziękujemy za zamówienie, {name}!", @@ -312,7 +322,8 @@ "shippingAddress": "Adres wysyłki", "billingAddress": "Adres rozliczeniowy", "orderNotFound": "Zamówienie nie zostało znalezione", - "failedToLoad": "Nie udało się załadować szczegółów zamówienia." + "failedToLoad": "Nie udało się załadować szczegółów zamówienia.", + "qty": "Ilość: {quantity}" }, "coupon": { "placeholder": "Karta podarunkowa lub kod rabatowy", @@ -342,7 +353,8 @@ "accountInformation": "Informacje o koncie", "accountId": "ID konta", "loadingProfile": "Ładowanie profilu...", - "failedToUpdate": "Nie udało się zaktualizować profilu" + "failedToUpdate": "Nie udało się zaktualizować profilu", + "email": "E-mail" }, "creditCards": { "paymentMethods": "Metody płatności", @@ -353,7 +365,9 @@ "removing": "Usuwanie...", "secureInfo": "Twoje dane płatnicze są bezpiecznie przechowywane.", "deleteConfirm": "Czy na pewno chcesz usunąć tę kartę?", - "cardMaskedLabel": "{label} kończąca się na {digits}, ważna do {month}/{year}" + "cardMaskedLabel": "{label} kończąca się na {digits}, ważna do {month}/{year}", + "removePaymentMethodTitle": "Usunąć metodę płatności?", + "endingIn": "kończąca się na" }, "giftCards": { "giftCards": "Karty podarunkowe", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 2af87db0..1d0b6b12 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -88,6 +88,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const basePath = extractBasePath(pathname); const { setSummaryContent } = useCheckout(); const t = useTranslations("checkout"); + const tc = useTranslations("common"); const stepLabels: Record = { address: t("stepShipping"), @@ -113,28 +114,34 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const beginCheckoutFiredRef = useRef(false); // Handle coupon code application - uses ref to avoid stale closures - const handleApplyCoupon = useCallback(async (code: string) => { - const currentOrder = orderRef.current; - if (!currentOrder) return { success: false, error: "No order" }; + const handleApplyCoupon = useCallback( + async (code: string) => { + const currentOrder = orderRef.current; + if (!currentOrder) return { success: false, error: t("noOrder") }; - const result = await applyCouponCode(currentOrder.id, code); - if (result.success && result.order) { - setOrder(result.order); - } - return result; - }, []); // No dependencies - uses ref + const result = await applyCouponCode(currentOrder.id, code); + if (result.success && result.order) { + setOrder(result.order); + } + return result; + }, + [t], + ); // t is stable from useTranslations // Handle coupon code removal - const handleRemoveCoupon = useCallback(async (promotionId: string) => { - const currentOrder = orderRef.current; - if (!currentOrder) return { success: false, error: "No order" }; + const handleRemoveCoupon = useCallback( + async (promotionId: string) => { + const currentOrder = orderRef.current; + if (!currentOrder) return { success: false, error: t("noOrder") }; - const result = await removeCouponCode(currentOrder.id, promotionId); - if (result.success && result.order) { - setOrder(result.order); - } - return result; - }, []); // No dependencies - uses ref + const result = await removeCouponCode(currentOrder.id, promotionId); + if (result.success && result.order) { + setOrder(result.order); + } + return result; + }, + [t], + ); // t is stable from useTranslations // Track order key for sidebar updates (only update when order changes meaningfully) const orderKey = order ? `${order.id}-${order.updated_at}` : null; @@ -259,7 +266,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { }); if (!updateResult.success) { - setError(updateResult.error || "Failed to save address"); + setError(updateResult.error || t("failedToSaveAddress")); setProcessing(false); return; } @@ -267,7 +274,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Move to next checkout step const nextResult = await nextCheckoutStep(order.id); if (!nextResult.success) { - setError(nextResult.error || "Failed to proceed to next step"); + setError(nextResult.error || t("failedToProceed")); setProcessing(false); return; } @@ -275,7 +282,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Reload order to get updated state await loadOrder(); } catch { - setError("An error occurred. Please try again."); + setError(t("generalError")); } finally { setProcessing(false); } @@ -297,7 +304,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { try { const result = await selectShippingRate(order.id, shipmentId, rateId); if (!result.success) { - setError(result.error || "Failed to select shipping rate"); + setError(result.error || t("failedToSelectRate")); } else if (result.order) { setOrder(result.order); setShipments(result.order.shipments || []); @@ -309,7 +316,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { trackingRateName = selectedRate?.name; } } catch { - setError("An error occurred. Please try again."); + setError(t("generalError")); } finally { setProcessing(false); } @@ -334,7 +341,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Move to next checkout step const nextResult = await nextCheckoutStep(order.id); if (!nextResult.success) { - setError(nextResult.error || "Failed to proceed"); + setError(nextResult.error || t("failedToProceed")); setProcessing(false); return; } @@ -342,7 +349,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Reload order await loadOrder(); } catch { - setError("An error occurred. Please try again."); + setError(t("generalError")); } finally { setProcessing(false); } @@ -362,13 +369,13 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { }); if (!updateResult.success) { - setError(updateResult.error || "Failed to save billing address"); + setError(updateResult.error || t("failedToSaveBilling")); return false; } return true; } catch { - setError("Failed to save billing address. Please try again."); + setError(t("failedToSaveBillingRetry")); return false; } }; @@ -387,7 +394,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { ); if (!sessionResult.success) { - setError(sessionResult.error || "Failed to complete payment session"); + setError(sessionResult.error || t("failedToCompletePaymentSession")); setProcessing(false); return; } @@ -403,7 +410,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const updatedOrder = await getCheckoutOrder(order.id); if (!updatedOrder) { - setError("Order not found after payment. Please contact support."); + setError(t("orderNotFoundAfterPayment")); setProcessing(false); return; } @@ -411,7 +418,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { if (updatedOrder.current_step !== "complete") { const completeResult = await completeCheckoutOrder(order.id); if (!completeResult.success) { - setError(completeResult.error || "Failed to complete order"); + setError(completeResult.error || t("failedToCompleteOrder")); setProcessing(false); return; } @@ -420,7 +427,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { // Redirect to order placed page (cart cookie is cleared there) router.push(`${basePath}/order-placed/${order.id}`); } catch { - setError("An error occurred. Please try again."); + setError(t("generalError")); setProcessing(false); } }; @@ -443,11 +450,11 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { const result = await updateAddress(id, data); if (!result.success) { - throw new Error(result.error || "Failed to update address"); + throw new Error(result.error || t("failedToSaveAddress")); } if (!result.address) { - throw new Error("Update succeeded but address payload is missing"); + throw new Error(t("generalError")); } const updatedAddress = result.address; @@ -510,7 +517,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { href={`${basePath}/products`} className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-700" > - {t("returnToCart")} + {tc("continueShopping")}
    ); diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[country]/[locale]/(checkout)/layout.tsx index dfbfa292..2ced98fb 100644 --- a/src/app/[country]/[locale]/(checkout)/layout.tsx +++ b/src/app/[country]/[locale]/(checkout)/layout.tsx @@ -62,6 +62,8 @@ function MobileSummaryToggle() { type="button" onClick={() => setIsOpen(!isOpen)} className="w-full px-5 py-4 flex items-center justify-between text-left" + aria-expanded={isOpen} + aria-controls="checkout-summary-panel" > @@ -72,7 +74,7 @@ function MobileSummaryToggle() { /> {isOpen && ( -
    +
    )} diff --git a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx index 7f78942e..f96f485b 100644 --- a/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/order-placed/[id]/page.tsx @@ -129,7 +129,7 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) {

    {customerName - ? t("thanksForOrder", { name: customerName.split(" ")[0] }) + ? t("thanksForOrder", { name: customerName }) : t("thanksForOrderAnonymous")}

    @@ -164,7 +164,9 @@ export default function OrderPlacedPage({ params }: OrderPlacedPageProps) { {item.options_text && (

    {item.options_text}

    )} -

    Qty: {item.quantity}

    +

    + {t("qty", { quantity: item.quantity })} +

    {item.display_total} diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx index 823ad496..4c092de3 100644 --- a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx @@ -36,6 +36,7 @@ function AddressCard({ }) { const t = useTranslations("account"); const tc = useTranslations("common"); + const ta = useTranslations("address"); const [deleting, setDeleting] = useState(false); const handleDelete = async () => { @@ -71,20 +72,20 @@ function AddressCard({ - Delete address? + {ta("deleteAddressTitle")} {t("deleteConfirm")} - Cancel + {tc("cancel")} - Delete + {ta("delete")} diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index 5cf10c9c..bce307b0 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -51,7 +51,7 @@ function CreditCardItem({ />

    - {getCardLabel(card.cc_type)} ending in {card.last_digits} + {getCardLabel(card.cc_type)} {t("endingIn")} {card.last_digits}

    {t("expires")} {String(card.month).padStart(2, "0")}/{card.year} @@ -75,13 +75,15 @@ function CreditCardItem({ - Remove payment method? + + {t("removePaymentMethodTitle")} + {t("deleteConfirm")} - Cancel + {tc("cancel")} {tc("remove")} diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx index 6d2a019d..33fb3e96 100644 --- a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx @@ -2,7 +2,7 @@ import type { GiftCard } from "@spree/sdk"; import { Check, ClipboardCopy, Gift, Info } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { getGiftCards } from "@/lib/data/gift-cards"; @@ -23,10 +23,10 @@ function getStateColor(state: string, expired: boolean): string { } } -function formatDate(dateString: string | null): string { +function formatDate(dateString: string | null, locale: string): string { if (!dateString) return ""; const date = new Date(dateString); - return date.toLocaleDateString("en-US", { + return date.toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric", @@ -60,7 +60,7 @@ function CopyButton({ code }: { code: string }) { variant="ghost" size="sm" onClick={handleCopy} - title="Copy code to clipboard" + title={t("copyCodeToClipboard")} > {copied ? ( <> @@ -79,6 +79,7 @@ function CopyButton({ code }: { code: string }) { function GiftCardItem({ card }: { card: GiftCard }) { const t = useTranslations("giftCards"); + const locale = useLocale(); const usagePercentage = Number(card.amount) > 0 ? Math.round((Number(card.amount_used) / Number(card.amount)) * 100) @@ -118,7 +119,7 @@ function GiftCardItem({ card }: { card: GiftCard }) {

    {card.expires_at - ? t("expiresOn", { date: formatDate(card.expires_at) }) + ? t("expiresOn", { date: formatDate(card.expires_at, locale) }) : t("noExpiration")}

    @@ -156,9 +157,15 @@ function GiftCardItem({ card }: { card: GiftCard }) { {/* Additional info */}
    - {t("addedOnDate", { date: formatDate(card.created_at) })} + + {t("addedOnDate", { date: formatDate(card.created_at, locale) })} + {card.redeemed_at && ( - Fully redeemed on {formatDate(card.redeemed_at)} + + {t("fullyRedeemedOnDate", { + date: formatDate(card.redeemed_at, locale), + })} + )}
    @@ -255,8 +262,7 @@ export default function GiftCardsPage() {

    - Gift cards can be used during checkout to pay for your orders. The - remaining balance will be saved for future purchases. + {t("giftCardsHelpText")}

    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx index a20fb91e..900dfb18 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx @@ -11,7 +11,7 @@ import type { import { ChevronLeft, CircleAlert } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { use, useEffect, useState } from "react"; import { PaymentIcon } from "react-svg-credit-card-payment-icons"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -19,11 +19,15 @@ import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; import { getOrder } from "@/lib/data/orders"; import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; +import { + getShipmentStatusColor, + SHIPMENT_STATE_KEY, +} from "@/lib/utils/order-status"; import { extractBasePath } from "@/lib/utils/path"; -function formatDate(dateString: string | null): string { +function formatDate(dateString: string | null, locale: string): string { if (!dateString) return "-"; - return new Date(dateString).toLocaleDateString("en-US", { + return new Date(dateString).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", @@ -32,21 +36,6 @@ function formatDate(dateString: string | null): string { }); } -function getShipmentStatusColor(state: string): string { - switch (state) { - case "shipped": - case "delivered": - return "bg-green-100 text-green-800"; - case "ready": - case "pending": - return "bg-yellow-100 text-yellow-800"; - case "canceled": - return "bg-red-100 text-red-800"; - default: - return "bg-gray-100 text-gray-800"; - } -} - function PaymentSourceInfo({ payment }: { payment: Payment }) { const t = useTranslations("orders"); const source = payment.source; @@ -84,8 +73,10 @@ function PaymentSourceInfo({ payment }: { payment: Payment }) {

    {t("storeCredit")}

    - Applied {payment.display_amount} — {credit.display_amount_remaining}{" "} - remaining + {t("storeCreditApplied", { + amount: payment.display_amount, + remaining: credit.display_amount_remaining, + })}

    ); @@ -163,7 +154,7 @@ function LineItemCard({ href={`${basePath}/products/${item.slug}`} className="mt-2 inline-block text-sm text-primary hover:text-primary font-medium" > - Order again + {t("orderAgain")}
    @@ -206,17 +197,17 @@ function ShipmentBlock({ {t("shippingMethod")}

    - {shipment.shipping_method?.name || "Canceled"} + {shipment.shipping_method?.name || t("canceled")}

    {shipment.stock_location && (

    - Shipped from {shipment.stock_location.name} + {t("shippedFrom", { location: shipment.stock_location.name })}

    )} - {shipment.state} + {t(SHIPMENT_STATE_KEY[shipment.state] || shipment.state)}
    @@ -242,16 +233,14 @@ function ShipmentBlock({ {shipment.state === "canceled" && !shipment.shipped_at && ( - - Shipment canceled — a refund has been issued. - + {t("shipmentCanceledRefund")} )} {shipment.state !== "canceled" && shipment.state !== "shipped" && !shipment.tracking && (
    - No tracking information present + {t("noTrackingInfo")}
    )}
    @@ -279,6 +268,7 @@ interface OrderDetailPageProps { export default function OrderDetailPage({ params }: OrderDetailPageProps) { const t = useTranslations("orders"); const tc = useTranslations("common"); + const locale = useLocale(); const { id } = use(params); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -313,9 +303,7 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) {

    {t("orderNotFound")}

    -

    - The order you're looking for doesn't exist. -

    +

    {t("orderNotFoundDescription")}

    - Order #{order.number} + {t("orderTitle", { number: order.number })}

    - {t("placedOn", { date: formatDate(order.completed_at) })} + {t("placedOn", { date: formatDate(order.completed_at, locale) })}

    {/* Shipments with line items */} diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx index bd411258..5a4a58b9 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx @@ -8,6 +8,12 @@ import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { getOrders } from "@/lib/data/orders"; +import { + getPaymentStatusColor, + getShipmentStatusColor, + PAYMENT_STATE_KEY, + SHIPMENT_STATE_KEY, +} from "@/lib/utils/order-status"; import { extractBasePath } from "@/lib/utils/path"; function formatDate(dateString: string | null, locale: string): string { @@ -19,55 +25,6 @@ function formatDate(dateString: string | null, locale: string): string { }); } -const PAYMENT_STATE_KEY: Record = { - paid: "paid", - balance_due: "balanceDue", - credit_owed: "creditOwed", - pending: "pending", - failed: "failed", - void: "void", -}; - -const SHIPMENT_STATE_KEY: Record = { - shipped: "shipped", - partial: "partiallyShipped", - delivered: "delivered", - ready: "ready", - pending: "pending", - canceled: "canceled", - backorder: "backorder", -}; - -function getPaymentStatusColor(state: string | null): string { - switch (state) { - case "paid": - return "bg-green-100 text-green-800"; - case "balance_due": - case "pending": - return "bg-yellow-100 text-yellow-800"; - case "failed": - case "void": - return "bg-red-100 text-red-800"; - default: - return "bg-gray-100 text-gray-800"; - } -} - -function getShipmentStatusColor(state: string | null): string { - switch (state) { - case "shipped": - case "delivered": - return "bg-green-100 text-green-800"; - case "ready": - case "pending": - return "bg-yellow-100 text-yellow-800"; - case "canceled": - return "bg-red-100 text-red-800"; - default: - return "bg-gray-100 text-gray-800"; - } -} - export default function OrdersPage() { const t = useTranslations("orders"); const tc = useTranslations("common"); diff --git a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx b/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx index 78930ffc..5ffad034 100644 --- a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx @@ -128,7 +128,9 @@ function ProfileForm({
    {user.id}
    -
    Email
    +
    + {t("email")} +
    {user.email}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx index 4be9a094..b61f4548 100644 --- a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx @@ -72,7 +72,7 @@ export default function RegisterPage() { setError(result.error || t("registrationFailed")); } } catch { - setError("An unexpected error occurred. Please try again."); + setError(t("unexpectedError")); } finally { setSubmitting(false); } @@ -98,7 +98,7 @@ export default function RegisterPage() {
    - First name + {t("firstName")} - Last name + {t("lastName")} handleRemove(item)} > {tc("remove")} diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx index fa018aca..964a957b 100644 --- a/src/app/[country]/[locale]/(storefront)/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server"; import { ProductCarousel } from "@/components/products/ProductCarousel"; import { Button } from "@/components/ui/button"; import { generateHomeMetadata } from "@/lib/metadata/home"; +import { getStoreName } from "@/lib/seo"; interface HomePageProps { params: Promise<{ @@ -23,6 +24,7 @@ export default async function HomePage({ params }: HomePageProps) { const { country, locale } = await params; const basePath = `/${country}/${locale}`; const t = await getTranslations("home"); + const storeName = getStoreName(); return (
    @@ -31,7 +33,7 @@ export default async function HomePage({ params }: HomePageProps) {

    - {t("welcome")} + {t("welcome", { storeName })}

    {t("heroDescription")} @@ -41,7 +43,7 @@ export default async function HomePage({ params }: HomePageProps) { {t("shopNow")} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 693d6f28..7a16763c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,8 @@ import { GoogleTagManager } from "@next/third-parties/google"; import type { Metadata } from "next"; import { Geist } from "next/font/google"; -import { cookies } from "next/headers"; import { NextIntlClientProvider } from "next-intl"; -import { getMessages } from "next-intl/server"; +import { getLocale, getMessages } from "next-intl/server"; import "./globals.css"; import { CartDrawer } from "@/components/cart/CartDrawer"; import { Toaster } from "@/components/ui/sonner"; @@ -32,8 +31,7 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const cookieStore = await cookies(); - const locale = cookieStore.get("spree_locale")?.value || "en"; + const locale = await getLocale(); const messages = await getMessages(); return ( diff --git a/src/components/checkout/AddressSelector.tsx b/src/components/checkout/AddressSelector.tsx index cfae53ac..e2fc6e36 100644 --- a/src/components/checkout/AddressSelector.tsx +++ b/src/components/checkout/AddressSelector.tsx @@ -80,10 +80,10 @@ export function AddressSelector({

    {/* Saved addresses selection */} {savedAddresses.length > 0 && ( -
    -

    +

    + {t("selectAnAddress")} -

    +
    {savedAddresses.map((address) => (
    -
    +
    )} {/* Address form (shown when "new" is selected or no saved addresses) */} diff --git a/src/components/navigation/Breadcrumbs.tsx b/src/components/navigation/Breadcrumbs.tsx index 6587c176..366822aa 100644 --- a/src/components/navigation/Breadcrumbs.tsx +++ b/src/components/navigation/Breadcrumbs.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { Category } from "@spree/sdk"; import { ChevronRight } from "lucide-react"; import Link from "next/link"; diff --git a/src/components/products/ProductCarousel.tsx b/src/components/products/ProductCarousel.tsx index 894f48de..d1df21ff 100644 --- a/src/components/products/ProductCarousel.tsx +++ b/src/components/products/ProductCarousel.tsx @@ -71,7 +71,7 @@ export function ProductCarousel({ if (products.length === 0) { return (
    -

    No products found.

    +

    {t("noProductsFound")}

    ); } diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index 823e6449..5dfe440a 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -167,7 +167,7 @@ export function SearchBar({ basePath }: SearchBarProps) { selectedIndex >= 0 ? `search-option-${selectedIndex}` : undefined } aria-autocomplete="list" - aria-label="Search" + aria-label={t("search")} /> diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 00178cb6..252aec2e 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -8,13 +8,16 @@ export default getRequestConfig(async ({ requestLocale }) => { // fall back to the spree_locale cookie, then default to "en". let locale = await requestLocale; - if (!locale || !supportedLocales.includes(locale)) { + if (locale && !supportedLocales.includes(locale)) { + // Invalid locale from route — default to "en" directly + locale = "en"; + } else if (!locale) { + // No locale from route — try cookie, then default to "en" const cookieStore = await cookies(); locale = cookieStore.get("spree_locale")?.value; - } - - if (!locale || !supportedLocales.includes(locale)) { - locale = "en"; + if (!locale || !supportedLocales.includes(locale)) { + locale = "en"; + } } let messages: IntlMessages; diff --git a/src/lib/utils/order-status.ts b/src/lib/utils/order-status.ts new file mode 100644 index 00000000..2958c297 --- /dev/null +++ b/src/lib/utils/order-status.ts @@ -0,0 +1,53 @@ +/** + * Maps raw Spree payment/shipment state strings to i18n translation keys + * under the "orders" namespace. + */ + +export const PAYMENT_STATE_KEY: Record = { + paid: "paid", + balance_due: "balanceDue", + credit_owed: "creditOwed", + pending: "pending", + failed: "failed", + void: "void", +}; + +export const SHIPMENT_STATE_KEY: Record = { + shipped: "shipped", + partial: "partiallyShipped", + delivered: "delivered", + ready: "ready", + pending: "pending", + canceled: "canceled", + backorder: "backorder", +}; + +export function getPaymentStatusColor(state: string | null): string { + switch (state) { + case "paid": + return "bg-green-100 text-green-800"; + case "balance_due": + case "pending": + return "bg-yellow-100 text-yellow-800"; + case "failed": + case "void": + return "bg-red-100 text-red-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} + +export function getShipmentStatusColor(state: string | null): string { + switch (state) { + case "shipped": + case "delivered": + return "bg-green-100 text-green-800"; + case "ready": + case "pending": + return "bg-yellow-100 text-yellow-800"; + case "canceled": + return "bg-red-100 text-red-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} From be133dcd59548f4fbcfb1c6d27d0aad11b34c6cb Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 12 Mar 2026 13:29:17 +0100 Subject: [PATCH 3/8] Add @swc/helpers as direct dependency to fix CI lockfile sync @swc/core from next-intl requires @swc/helpers >=0.5.17 but the lockfile only had 0.5.15 via next. Adding it explicitly at 0.5.19 ensures npm ci resolves correctly. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 17 ++++++++++++++--- package.json | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9886bbc..bad4d689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@spree/sdk": "^0.9.0", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@swc/helpers": "^0.5.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", @@ -6078,10 +6079,11 @@ "license": "Apache-2.0" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -10360,6 +10362,15 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 996d7a87..d68e62fd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@spree/sdk": "^0.9.0", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", + "@swc/helpers": "^0.5.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", From a9d8024d2d85dca1218ec27b1567bb76449f573c Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Thu, 12 Mar 2026 13:35:38 +0100 Subject: [PATCH 4/8] Use parameterized translation keys for credit card labels Replace concatenated translation fragments with cardEndingIn and cardExpires keys so translators can control word order per locale. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 4 +++- messages/en.json | 4 +++- messages/pl.json | 4 +++- .../(storefront)/account/credit-cards/page.tsx | 10 ++++++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/messages/de.json b/messages/de.json index 5c8af28f..7a0904fc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -367,7 +367,9 @@ "deleteConfirm": "Sind Sie sicher, dass Sie diese Karte entfernen möchten?", "cardMaskedLabel": "{label} endet auf {digits}, gültig bis {month}/{year}", "removePaymentMethodTitle": "Zahlungsmethode entfernen?", - "endingIn": "endet auf" + "endingIn": "endet auf", + "cardEndingIn": "{label} endet auf {digits}", + "cardExpires": "Gültig bis {month}/{year}" }, "giftCards": { "giftCards": "Geschenkkarten", diff --git a/messages/en.json b/messages/en.json index 6fad8f8e..cee8f181 100644 --- a/messages/en.json +++ b/messages/en.json @@ -367,7 +367,9 @@ "deleteConfirm": "Are you sure you want to remove this card?", "cardMaskedLabel": "{label} ending in {digits}, expires {month}/{year}", "removePaymentMethodTitle": "Remove payment method?", - "endingIn": "ending in" + "endingIn": "ending in", + "cardEndingIn": "{label} ending in {digits}", + "cardExpires": "Expires {month}/{year}" }, "giftCards": { "giftCards": "Gift Cards", diff --git a/messages/pl.json b/messages/pl.json index f907b673..0db864e1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -367,7 +367,9 @@ "deleteConfirm": "Czy na pewno chcesz usunąć tę kartę?", "cardMaskedLabel": "{label} kończąca się na {digits}, ważna do {month}/{year}", "removePaymentMethodTitle": "Usunąć metodę płatności?", - "endingIn": "kończąca się na" + "endingIn": "kończąca się na", + "cardEndingIn": "{label} kończąca się na {digits}", + "cardExpires": "Ważna do {month}/{year}" }, "giftCards": { "giftCards": "Karty podarunkowe", diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx index bce307b0..fc3135f3 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx @@ -51,10 +51,16 @@ function CreditCardItem({ />

    - {getCardLabel(card.cc_type)} {t("endingIn")} {card.last_digits} + {t("cardEndingIn", { + label: getCardLabel(card.cc_type), + digits: card.last_digits, + })}

    - {t("expires")} {String(card.month).padStart(2, "0")}/{card.year} + {t("cardExpires", { + month: String(card.month).padStart(2, "0"), + year: card.year, + })}

    {card.name && (

    {card.name}

    From 092044090563e5ddcb4c5d2374ad622b60182f12 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Fri, 13 Mar 2026 13:15:39 +0100 Subject: [PATCH 5/8] Remove unused Order import from PaymentStep after SDK 0.9 rebase Co-Authored-By: Claude Opus 4.6 --- src/components/checkout/PaymentStep.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/checkout/PaymentStep.tsx b/src/components/checkout/PaymentStep.tsx index f3d287a9..9a544f67 100644 --- a/src/components/checkout/PaymentStep.tsx +++ b/src/components/checkout/PaymentStep.tsx @@ -4,7 +4,6 @@ import type { AddressParams, Cart, Country, - Order, CreditCard as SpreeCreditCard, State, } from "@spree/sdk"; From 8697d1709ad61d8dc213128613bffddb45d2c600 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Fri, 13 Mar 2026 13:56:32 +0100 Subject: [PATCH 6/8] Fix code review issues: expired label, fallback translations, error handling, a11y - Gift cards: getStateLabel now checks expired flag to match getStateColor - Order detail: shipment state fallback uses translation key instead of raw API value - Homepage: fix broken /categories link to point to /products - Orders page: add try/catch/finally to loadOrders to prevent stuck loading state - Checkout layout: make aria-controls conditional on panel visibility - Account layout: rename useNavItems to getNavItems (not a hook) Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 1 + messages/en.json | 1 + messages/pl.json | 1 + src/app/[country]/[locale]/(checkout)/layout.tsx | 2 +- .../(storefront)/account/gift-cards/page.tsx | 5 +++-- .../[locale]/(storefront)/account/layout.tsx | 4 ++-- .../(storefront)/account/orders/[id]/page.tsx | 4 +++- .../[locale]/(storefront)/account/orders/page.tsx | 13 +++++++++---- src/app/[country]/[locale]/(storefront)/page.tsx | 2 +- 9 files changed, 22 insertions(+), 11 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7a0904fc..9209c431 100644 --- a/messages/de.json +++ b/messages/de.json @@ -309,6 +309,7 @@ "failed": "Fehlgeschlagen", "void": "Storniert", "notAvailable": "k. A.", + "unknownShipmentStatus": "Unbekannt", "orderTitle": "Bestellung #{number}", "shipmentCanceledRefund": "Sendung storniert — eine Rückerstattung wurde veranlasst." }, diff --git a/messages/en.json b/messages/en.json index cee8f181..05c2e370 100644 --- a/messages/en.json +++ b/messages/en.json @@ -309,6 +309,7 @@ "failed": "Failed", "void": "Void", "notAvailable": "N/A", + "unknownShipmentStatus": "Unknown", "orderTitle": "Order #{number}", "shipmentCanceledRefund": "Shipment canceled — a refund has been issued." }, diff --git a/messages/pl.json b/messages/pl.json index 0db864e1..bcf76e50 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -309,6 +309,7 @@ "failed": "Nieudane", "void": "Unieważnione", "notAvailable": "Brak", + "unknownShipmentStatus": "Nieznany", "orderTitle": "Zamówienie #{number}", "shipmentCanceledRefund": "Przesyłka anulowana — zwrot został wystawiony." }, diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[country]/[locale]/(checkout)/layout.tsx index 2ced98fb..20869b9c 100644 --- a/src/app/[country]/[locale]/(checkout)/layout.tsx +++ b/src/app/[country]/[locale]/(checkout)/layout.tsx @@ -63,7 +63,7 @@ function MobileSummaryToggle() { onClick={() => setIsOpen(!isOpen)} className="w-full px-5 py-4 flex items-center justify-between text-left" aria-expanded={isOpen} - aria-controls="checkout-summary-panel" + {...(isOpen && { "aria-controls": "checkout-summary-panel" })} > diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx index 33fb3e96..bfe05c7c 100644 --- a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx @@ -85,7 +85,8 @@ function GiftCardItem({ card }: { card: GiftCard }) { ? Math.round((Number(card.amount_used) / Number(card.amount)) * 100) : 0; - function getStateLabel(state: string): string { + function getStateLabel(state: string, expired?: boolean): string { + if (expired) return t("expired"); switch (state) { case "active": return t("active"); @@ -114,7 +115,7 @@ function GiftCardItem({ card }: { card: GiftCard }) { - {getStateLabel(card.state)} + {getStateLabel(card.state, card.expired)}

    diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx index 249c93b6..4cacaa0d 100644 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/layout.tsx @@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; -function useNavItems(t: ReturnType>): { +function getNavItems(t: ReturnType>): { href: string; label: string; icon: LucideIcon; @@ -70,7 +70,7 @@ function AccountShell({ isLoading, }: AccountShellProps) { const t = useTranslations("account"); - const navItems = useNavItems(t); + const navItems = getNavItems(t); return (

    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx index 900dfb18..c80064d7 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx @@ -207,7 +207,9 @@ function ShipmentBlock({ - {t(SHIPMENT_STATE_KEY[shipment.state] || shipment.state)} + {t( + SHIPMENT_STATE_KEY[shipment.state] || "unknownShipmentStatus", + )}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx index 5a4a58b9..60e2dd25 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx @@ -36,10 +36,15 @@ export default function OrdersPage() { useEffect(() => { async function loadOrders() { - const response = await getOrders({ limit: 50 }); - // Filter to only show completed orders - setOrders(response.data.filter((o) => o.completed_at !== null)); - setLoading(false); + try { + const response = await getOrders({ limit: 50 }); + // Filter to only show completed orders + setOrders(response.data.filter((o) => o.completed_at !== null)); + } catch { + setOrders([]); + } finally { + setLoading(false); + } } loadOrders(); }, []); diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx index 964a957b..a1140604 100644 --- a/src/app/[country]/[locale]/(storefront)/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/page.tsx @@ -43,7 +43,7 @@ export default async function HomePage({ params }: HomePageProps) { {t("shopNow")} From 4781628d6eacd370073cabfd70069966015e2131 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 16 Mar 2026 11:13:36 +0100 Subject: [PATCH 7/8] Add i18n translations to new pages from main merge Translate all hardcoded strings in 7 new/updated files from the main merge (one-page checkout, forgot/reset password, confirm payment). Add forgotPassword and resetPassword namespaces to all locales (en/pl/de) and sync missing checkout keys across translation files. Co-Authored-By: Claude Opus 4.6 --- messages/de.json | 45 +++++++++++++++-- messages/en.json | 45 +++++++++++++++-- messages/pl.json | 45 +++++++++++++++-- .../(checkout)/confirm-payment/[id]/page.tsx | 8 +-- .../account/forgot-password/page.tsx | 34 ++++++------- .../(storefront)/account/orders/[id]/page.tsx | 34 +++++-------- .../[locale]/(storefront)/account/page.tsx | 4 +- .../(storefront)/account/register/page.tsx | 8 +-- .../account/reset-password/page.tsx | 49 +++++++++---------- src/components/checkout/AddressSection.tsx | 23 ++++++--- src/components/checkout/PaymentSection.tsx | 45 ++++++++--------- .../checkout/ShippingMethodSection.tsx | 19 +++++-- src/components/checkout/Summary.tsx | 17 ++++--- 13 files changed, 252 insertions(+), 124 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9209c431..fb4453a1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -162,6 +162,7 @@ "continueToDelivery": "Weiter zur Lieferung", "shippingMethod": "Versandmethode", "loadingShippingOptions": "Versandoptionen werden geladen...", + "enterShippingAddressForMethods": "Geben Sie Ihre Lieferadresse ein, um verfügbare Versandmethoden anzuzeigen.", "shipmentOf": "Sendung {current} von {total}", "shipsFrom": "Versand von {location}", "phone": "Telefon: {phone}", @@ -174,6 +175,9 @@ "exp": "Gültig bis", "default": "Standard", "noPaymentMethods": "Für diese Bestellung sind keine Zahlungsmethoden verfügbar.", + "secureTransactions": "Alle Transaktionen sind sicher und verschlüsselt.", + "creditCard": "Kreditkarte", + "enterShippingAddress": "Lieferadresse eingeben", "payNow": "Jetzt bezahlen", "failedToInitPayment": "Zahlung konnte nicht initialisiert werden. Bitte versuchen Sie es erneut.", "failedToCreateSession": "Zahlungssitzung konnte nicht erstellt werden.", @@ -191,7 +195,8 @@ "stripeNotLoaded": "Stripe wurde noch nicht geladen", "paymentProcessingError": "Bei der Zahlung ist ein Fehler aufgetreten.", "failedToCompletePaymentSession": "Zahlungssitzung konnte nicht abgeschlossen werden", - "noOrder": "Keine Bestellung" + "noOrder": "Keine Bestellung", + "confirmingPayment": "Ihre Zahlung wird bestätigt..." }, "address": { "firstName": "Vorname", @@ -248,7 +253,8 @@ "deleteConfirm": "Sind Sie sicher, dass Sie diese Adresse löschen möchten?", "setAsDefault": "Als Standard festlegen", "showPassword": "Passwort anzeigen", - "hidePassword": "Passwort verbergen" + "hidePassword": "Passwort verbergen", + "forgotPassword": "Passwort vergessen?" }, "register": { "createAccount": "Konto erstellen", @@ -311,7 +317,8 @@ "notAvailable": "k. A.", "unknownShipmentStatus": "Unbekannt", "orderTitle": "Bestellung #{number}", - "shipmentCanceledRefund": "Sendung storniert — eine Rückerstattung wurde veranlasst." + "shipmentCanceledRefund": "Sendung storniert — eine Rückerstattung wurde veranlasst.", + "shippingMethodUnavailable": "Nicht verfügbar" }, "orderPlaced": { "thanksForOrder": "Vielen Dank für Ihre Bestellung, {name}!", @@ -395,5 +402,37 @@ "expiresOn": "Läuft ab am {date}", "percentUsed": "{percent}% verwendet", "giftCardsHelpText": "Geschenkkarten können beim Checkout zum Bezahlen Ihrer Bestellungen verwendet werden. Das verbleibende Guthaben wird für zukünftige Einkäufe gespeichert." + }, + "forgotPassword": { + "title": "Passwort zurücksetzen", + "description": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.", + "email": "E-Mail", + "sending": "Wird gesendet...", + "sendResetLink": "Link zum Zurücksetzen senden", + "backToSignIn": "Zurück zur Anmeldung", + "checkYourEmail": "Überprüfen Sie Ihre E-Mail", + "resetEmailSent": "Wenn ein Konto für {email} existiert, haben wir Anweisungen zum Zurücksetzen des Passworts gesendet.", + "linkExpiry": "Der Link in der E-Mail läuft in 15 Minuten ab. Überprüfen Sie Ihren Spam-Ordner, wenn Sie ihn nicht sehen.", + "tryDifferentEmail": "Andere E-Mail-Adresse versuchen", + "genericError": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." + }, + "resetPassword": { + "title": "Neues Passwort festlegen", + "description": "Geben Sie unten Ihr neues Passwort ein.", + "newPassword": "Neues Passwort", + "confirmPassword": "Neues Passwort bestätigen", + "resetting": "Wird zurückgesetzt...", + "resetPassword": "Passwort zurücksetzen", + "backToSignIn": "Zurück zur Anmeldung", + "invalidLink": "Ungültiger Link", + "invalidLinkDescription": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.", + "requestNewLink": "Neuen Link anfordern", + "passwordsDontMatch": "Passwörter stimmen nicht überein", + "passwordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein", + "linkExpired": "Dieser Link ist abgelaufen. Bitte fordern Sie einen neuen an.", + "genericError": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", + "success": "Passwort erfolgreich zurückgesetzt", + "successDescription": "Ihr Passwort wurde aktualisiert. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.", + "signIn": "Anmelden" } } diff --git a/messages/en.json b/messages/en.json index 05c2e370..6df82b92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -162,6 +162,7 @@ "continueToDelivery": "Continue to Delivery", "shippingMethod": "Shipping Method", "loadingShippingOptions": "Loading shipping options...", + "enterShippingAddressForMethods": "Enter your shipping address to view available shipping methods.", "shipmentOf": "Shipment {current} of {total}", "shipsFrom": "Ships from {location}", "phone": "Phone: {phone}", @@ -174,6 +175,9 @@ "exp": "Exp", "default": "Default", "noPaymentMethods": "No payment methods available for this order.", + "secureTransactions": "All transactions are secure and encrypted.", + "creditCard": "Credit card", + "enterShippingAddress": "Enter shipping address", "payNow": "Pay Now", "failedToInitPayment": "Failed to initialize payment. Please try again.", "failedToCreateSession": "Failed to create payment session.", @@ -191,7 +195,8 @@ "stripeNotLoaded": "Stripe has not loaded yet", "paymentProcessingError": "An error occurred during payment.", "failedToCompletePaymentSession": "Failed to complete payment session", - "noOrder": "No order" + "noOrder": "No order", + "confirmingPayment": "Confirming your payment..." }, "address": { "firstName": "First name", @@ -248,7 +253,8 @@ "deleteConfirm": "Are you sure you want to delete this address?", "setAsDefault": "Set as default", "showPassword": "Show password", - "hidePassword": "Hide password" + "hidePassword": "Hide password", + "forgotPassword": "Forgot password?" }, "register": { "createAccount": "Create Account", @@ -311,7 +317,8 @@ "notAvailable": "N/A", "unknownShipmentStatus": "Unknown", "orderTitle": "Order #{number}", - "shipmentCanceledRefund": "Shipment canceled — a refund has been issued." + "shipmentCanceledRefund": "Shipment canceled — a refund has been issued.", + "shippingMethodUnavailable": "Unavailable" }, "orderPlaced": { "thanksForOrder": "Thanks for your order, {name}!", @@ -395,5 +402,37 @@ "expiresOn": "Expires {date}", "percentUsed": "{percent}% used", "giftCardsHelpText": "Gift cards can be used during checkout to pay for your orders. The remaining balance will be saved for future purchases." + }, + "forgotPassword": { + "title": "Reset your password", + "description": "Enter your email and we'll send you a link to reset your password.", + "email": "Email", + "sending": "Sending...", + "sendResetLink": "Send reset link", + "backToSignIn": "Back to sign in", + "checkYourEmail": "Check your email", + "resetEmailSent": "If an account exists for {email}, we've sent password reset instructions.", + "linkExpiry": "The link in the email will expire in 15 minutes. Check your spam folder if you don't see it.", + "tryDifferentEmail": "Try a different email", + "genericError": "Something went wrong. Please try again." + }, + "resetPassword": { + "title": "Set a new password", + "description": "Enter your new password below.", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "resetting": "Resetting...", + "resetPassword": "Reset password", + "backToSignIn": "Back to sign in", + "invalidLink": "Invalid link", + "invalidLinkDescription": "This password reset link is invalid or has expired.", + "requestNewLink": "Request a new reset link", + "passwordsDontMatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters", + "linkExpired": "This reset link has expired. Please request a new one.", + "genericError": "Something went wrong. Please try again.", + "success": "Password reset successful", + "successDescription": "Your password has been updated. You can now sign in with your new password.", + "signIn": "Sign in" } } diff --git a/messages/pl.json b/messages/pl.json index bcf76e50..ac005fe6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -162,6 +162,7 @@ "continueToDelivery": "Przejdź do dostawy", "shippingMethod": "Metoda wysyłki", "loadingShippingOptions": "Ładowanie opcji wysyłki...", + "enterShippingAddressForMethods": "Podaj adres wysyłki, aby zobaczyć dostępne metody dostawy.", "shipmentOf": "Przesyłka {current} z {total}", "shipsFrom": "Wysyłka z {location}", "phone": "Telefon: {phone}", @@ -174,6 +175,9 @@ "exp": "Ważna do", "default": "Domyślna", "noPaymentMethods": "Brak dostępnych metod płatności dla tego zamówienia.", + "secureTransactions": "Wszystkie transakcje są bezpieczne i szyfrowane.", + "creditCard": "Karta kredytowa", + "enterShippingAddress": "Podaj adres wysyłki", "payNow": "Zapłać teraz", "failedToInitPayment": "Nie udało się zainicjować płatności. Spróbuj ponownie.", "failedToCreateSession": "Nie udało się utworzyć sesji płatności.", @@ -191,7 +195,8 @@ "stripeNotLoaded": "Stripe nie został jeszcze załadowany", "paymentProcessingError": "Wystąpił błąd podczas przetwarzania płatności.", "failedToCompletePaymentSession": "Nie udało się zakończyć sesji płatności", - "noOrder": "Brak zamówienia" + "noOrder": "Brak zamówienia", + "confirmingPayment": "Potwierdzanie płatności..." }, "address": { "firstName": "Imię", @@ -248,7 +253,8 @@ "deleteConfirm": "Czy na pewno chcesz usunąć ten adres?", "setAsDefault": "Ustaw jako domyślny", "showPassword": "Pokaż hasło", - "hidePassword": "Ukryj hasło" + "hidePassword": "Ukryj hasło", + "forgotPassword": "Zapomniałeś hasła?" }, "register": { "createAccount": "Utwórz konto", @@ -311,7 +317,8 @@ "notAvailable": "Brak", "unknownShipmentStatus": "Nieznany", "orderTitle": "Zamówienie #{number}", - "shipmentCanceledRefund": "Przesyłka anulowana — zwrot został wystawiony." + "shipmentCanceledRefund": "Przesyłka anulowana — zwrot został wystawiony.", + "shippingMethodUnavailable": "Niedostępna" }, "orderPlaced": { "thanksForOrder": "Dziękujemy za zamówienie, {name}!", @@ -395,5 +402,37 @@ "expiresOn": "Wygasa {date}", "percentUsed": "{percent}% wykorzystano", "giftCardsHelpText": "Karty podarunkowe mogą być używane podczas składania zamówienia do opłacenia zakupów. Pozostałe saldo zostanie zapisane na przyszłe zakupy." + }, + "forgotPassword": { + "title": "Zresetuj hasło", + "description": "Podaj swój adres e-mail, a wyślemy Ci link do zresetowania hasła.", + "email": "E-mail", + "sending": "Wysyłanie...", + "sendResetLink": "Wyślij link resetujący", + "backToSignIn": "Wróć do logowania", + "checkYourEmail": "Sprawdź swoją skrzynkę", + "resetEmailSent": "Jeśli konto istnieje dla {email}, wysłaliśmy instrukcje resetowania hasła.", + "linkExpiry": "Link w wiadomości wygaśnie za 15 minut. Sprawdź folder spam, jeśli go nie widzisz.", + "tryDifferentEmail": "Spróbuj inny adres e-mail", + "genericError": "Coś poszło nie tak. Spróbuj ponownie." + }, + "resetPassword": { + "title": "Ustaw nowe hasło", + "description": "Wpisz poniżej nowe hasło.", + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź nowe hasło", + "resetting": "Resetowanie...", + "resetPassword": "Zresetuj hasło", + "backToSignIn": "Wróć do logowania", + "invalidLink": "Nieprawidłowy link", + "invalidLinkDescription": "Ten link do resetowania hasła jest nieprawidłowy lub wygasł.", + "requestNewLink": "Poproś o nowy link resetujący", + "passwordsDontMatch": "Hasła nie są takie same", + "passwordTooShort": "Hasło musi mieć co najmniej 6 znaków", + "linkExpired": "Ten link resetujący wygasł. Poproś o nowy.", + "genericError": "Coś poszło nie tak. Spróbuj ponownie.", + "success": "Hasło zostało zresetowane", + "successDescription": "Twoje hasło zostało zaktualizowane. Możesz się teraz zalogować nowym hasłem.", + "signIn": "Zaloguj się" } } diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx index 5587d679..ad89b41d 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { use, useEffect, useRef } from "react"; import { confirmPaymentAndCompleteCart } from "@/lib/data/payment"; import { extractBasePath } from "@/lib/utils/path"; @@ -27,6 +28,7 @@ export default function ConfirmPaymentPage({ params, }: ConfirmPaymentPageProps) { const { id: cartId } = use(params); + const t = useTranslations("checkout"); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -57,7 +59,7 @@ export default function ConfirmPaymentPage({ router.replace(`${basePath}/order-placed/${cartId}`); } else { const errorMessage = encodeURIComponent( - result.error || "Payment could not be confirmed. Please try again.", + result.error || t("paymentError"), ); router.replace( `${basePath}/checkout/${cartId}?payment_error=${errorMessage}`, @@ -66,12 +68,12 @@ export default function ConfirmPaymentPage({ } confirmAndRedirect(); - }, [cartId, searchParams, basePath, router]); + }, [cartId, searchParams, basePath, router, t]); return (
    -

    Confirming your payment...

    +

    {t("confirmingPayment")}

    ); } diff --git a/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx b/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx index 0a601d04..79ff9a36 100644 --- a/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx @@ -3,6 +3,7 @@ import { CircleAlert, CircleCheck, Mail } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -20,6 +21,7 @@ import { requestPasswordReset } from "@/lib/data/customer"; import { extractBasePath } from "@/lib/utils/path"; export default function ForgotPasswordPage() { + const t = useTranslations("forgotPassword"); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -40,10 +42,10 @@ export default function ForgotPasswordPage() { if (result?.message) { setSubmitted(true); } else { - setError("Something went wrong. Please try again."); + setError(t("genericError")); } } catch { - setError("Something went wrong. Please try again."); + setError(t("genericError")); } finally { setSubmitting(false); } @@ -57,20 +59,19 @@ export default function ForgotPasswordPage() {
    - Check your email + {t("checkYourEmail")} - If an account exists for {email}, we've sent - password reset instructions. + {t.rich("resetEmailSent", { + email, + strong: (chunks) => {chunks}, + })}
    -

    - The link in the email will expire in 15 minutes. Check your spam - folder if you don't see it. -

    +

    {t("linkExpiry")}

    @@ -90,7 +91,7 @@ export default function ForgotPasswordPage() { href={`${basePath}/account`} className="text-sm text-primary hover:text-primary/70 font-medium" > - Back to sign in + {t("backToSignIn")} @@ -102,11 +103,8 @@ export default function ForgotPasswordPage() {
    - Reset your password - - Enter your email and we'll send you a link to reset your - password. - + {t("title")} + {t("description")} @@ -119,7 +117,7 @@ export default function ForgotPasswordPage() { )} - Email + {t("email")} - {submitting ? "Sending..." : "Send reset link"} + {submitting ? t("sending") : t("sendResetLink")}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx index 0f1364dd..82df64ad 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx @@ -19,6 +19,10 @@ import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; import { getOrder } from "@/lib/data/orders"; import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; +import { + getShipmentStatusColor, + SHIPMENT_STATE_KEY, +} from "@/lib/utils/order-status"; import { extractBasePath } from "@/lib/utils/path"; function formatDate(dateString: string | null, locale: string): string { @@ -32,21 +36,6 @@ function formatDate(dateString: string | null, locale: string): string { }); } -function getShipmentStatusColor(state: string): string { - switch (state) { - case "shipped": - case "delivered": - return "bg-green-100 text-green-800"; - case "ready": - case "pending": - return "bg-yellow-100 text-yellow-800"; - case "canceled": - return "bg-red-100 text-red-800"; - default: - return "bg-gray-100 text-gray-800"; - } -} - function PaymentSourceInfo({ payment }: { payment: Payment }) { const t = useTranslations("orders"); const source = payment.source; @@ -202,7 +191,8 @@ function ShipmentBlock({ {t("shippingMethod")}

    - {shipment.shipping_method?.name || t("canceled")} + {shipment.shipping_method?.name || + t("shippingMethodUnavailable")}

    {shipment.stock_location && (

    @@ -212,7 +202,9 @@ function ShipmentBlock({ - {shipment.state} + {t( + SHIPMENT_STATE_KEY[shipment.state] || "unknownShipmentStatus", + )}

    @@ -228,7 +220,7 @@ function ShipmentBlock({ ) : ( )}
    @@ -238,16 +230,14 @@ function ShipmentBlock({ {shipment.state === "canceled" && !shipment.shipped_at && ( - - {t("shipmentCanceled")} — {t("refundIssued")} - + {t("shipmentCanceledRefund")} )} {shipment.state !== "canceled" && shipment.state !== "shipped" && !shipment.tracking && (
    - {t("noTracking")} + {t("noTrackingInfo")}
    )}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 3adec0f5..539605f6 100644 --- a/src/app/[country]/[locale]/(storefront)/account/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/page.tsx @@ -57,7 +57,7 @@ export default function AccountPage() { router.push(redirectUrl); } } else { - setError(result.error || "Invalid email or password"); + setError(result.error || t("invalidCredentials")); } setLoading(false); }; @@ -147,7 +147,7 @@ export default function AccountPage() { href={`${basePath}/account/forgot-password`} className="text-sm text-primary hover:text-primary/70 font-medium" > - Forgot password? + {t("forgotPassword")}
    diff --git a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx index b2cae104..318b19bb 100644 --- a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/register/page.tsx @@ -55,12 +55,12 @@ export default function RegisterPage() { setError(null); if (password !== passwordConfirmation) { - setError("Passwords do not match"); + setError(t("passwordsDontMatch")); return; } if (password.length < 6) { - setError("Password must be at least 6 characters"); + setError(t("passwordTooShort")); return; } @@ -77,10 +77,10 @@ export default function RegisterPage() { if (result.success) { router.push(`${basePath}/account`); } else { - setError(result.error || "Registration failed. Please try again."); + setError(result.error || t("registrationFailed")); } } catch { - setError("An unexpected error occurred. Please try again."); + setError(t("unexpectedError")); } finally { setSubmitting(false); } diff --git a/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx b/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx index 2197096b..fbc558d8 100644 --- a/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx @@ -3,6 +3,7 @@ import { CircleAlert, CircleCheck, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -20,6 +21,8 @@ import { resetPassword } from "@/lib/data/customer"; import { extractBasePath } from "@/lib/utils/path"; export default function ResetPasswordPage() { + const t = useTranslations("resetPassword"); + const ta = useTranslations("account"); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -41,10 +44,8 @@ export default function ResetPasswordPage() {
    - Invalid link - - This password reset link is invalid or has expired. - + {t("invalidLink")} + {t("invalidLinkDescription")} @@ -52,7 +53,7 @@ export default function ResetPasswordPage() { href={`${basePath}/account/forgot-password`} className="text-sm text-primary hover:text-primary/70 font-medium" > - Request a new reset link + {t("requestNewLink")} @@ -65,12 +66,12 @@ export default function ResetPasswordPage() { setError(null); if (password !== passwordConfirmation) { - setError("Passwords do not match"); + setError(t("passwordsDontMatch")); return; } if (password.length < 6) { - setError("Password must be at least 6 characters"); + setError(t("passwordTooShort")); return; } @@ -81,13 +82,10 @@ export default function ResetPasswordPage() { if (result.success) { setSuccess(true); } else { - setError( - result.error || - "This reset link has expired. Please request a new one.", - ); + setError(result.error || t("linkExpired")); } } catch { - setError("Something went wrong. Please try again."); + setError(t("genericError")); } finally { setSubmitting(false); } @@ -101,11 +99,8 @@ export default function ResetPasswordPage() {
    - Password reset successful - - Your password has been updated. You can now sign in with your new - password. - + {t("success")} + {t("successDescription")} @@ -114,7 +109,7 @@ export default function ResetPasswordPage() { className="w-full" onClick={() => router.push(`${basePath}/account`)} > - Sign in + {t("signIn")} @@ -126,8 +121,8 @@ export default function ResetPasswordPage() {
    - Set a new password - Enter your new password below. + {t("title")} + {t("description")} @@ -140,7 +135,7 @@ export default function ResetPasswordPage() { )} - New password + {t("newPassword")}
    setShowPassword(!showPassword)} aria-label={ - showPassword ? "Hide password" : "Show password" + showPassword ? ta("hidePassword") : ta("showPassword") } > {showPassword ? ( @@ -175,7 +170,7 @@ export default function ResetPasswordPage() { - Confirm new password + {t("confirmPassword")}
    {showPasswordConfirmation ? ( @@ -220,7 +215,7 @@ export default function ResetPasswordPage() { size="lg" className="w-full" > - {submitting ? "Resetting..." : "Reset password"} + {submitting ? t("resetting") : t("resetPassword")}
    @@ -231,7 +226,7 @@ export default function ResetPasswordPage() { href={`${basePath}/account`} className="text-sm text-primary hover:text-primary/70 font-medium" > - Back to sign in + {t("backToSignIn")} diff --git a/src/components/checkout/AddressSection.tsx b/src/components/checkout/AddressSection.tsx index 27f25b7f..a4438953 100644 --- a/src/components/checkout/AddressSection.tsx +++ b/src/components/checkout/AddressSection.tsx @@ -3,6 +3,7 @@ import type { Address, AddressParams, Cart, Country, State } from "@spree/sdk"; import { Loader2 } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import { AddressEditModal } from "@/components/checkout/AddressEditModal"; import { AddressFormFields } from "@/components/checkout/AddressFormFields"; @@ -75,6 +76,10 @@ export function AddressSection({ saving, processing, }: AddressSectionProps) { + const t = useTranslations("checkout"); + const tc = useTranslations("common"); + const ta = useTranslations("address"); + // Determine initial saved address: use the first saved address when the // cart doesn't have a shipping address yet (authenticated users). const initialSavedAddress = @@ -229,13 +234,15 @@ export function AddressSection({ {/* Contact section */}
    -

    Contact

    +

    + {t("contactInformation")} +

    {!isAuthenticated && ( - Sign in + {t("signIn")} )}
    @@ -247,11 +254,11 @@ export function AddressSection({ onChange={(e) => setEmail(e.target.value)} onBlur={handleEmailBlur} disabled={isAuthenticated} - placeholder="Email" + placeholder={t("emailAddress")} /> {isAuthenticated && (

    - Using your account email address + {t("usingAccountEmail")}

    )}
    @@ -259,11 +266,13 @@ export function AddressSection({ {/* Delivery section */}
    -

    Delivery

    +

    + {t("shippingAddress")} +

    {saving && ( - Saving + {tc("saving")} )}
    @@ -306,7 +315,7 @@ export function AddressSection({ fetchStates={fetchStates} onSave={handleSaveEditedAddress} onClose={() => setEditingAddress(null)} - title="Edit Address" + title={ta("editAddress")} /> )} diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 3195c32a..d64f66ca 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -8,6 +8,7 @@ import type { State, } from "@spree/sdk"; import { CircleAlert, CreditCard, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { forwardRef, useCallback, @@ -74,6 +75,8 @@ export const PaymentSection = forwardRef< }, ref, ) { + const t = useTranslations("checkout"); + // Initialize billing address from cart, check if it matches shipping const shipAddressData = useMemo( () => addressToFormData(cart.ship_address), @@ -146,21 +149,21 @@ export const PaymentSection = forwardRef< setClientSecret(secret); setPaymentSessionId(result.session.id); } else { - setGatewayError("Failed to initialize payment. Please try again."); + setGatewayError(t("failedToInitPayment")); } } else if (!result.success) { - setGatewayError(result.error || "Failed to create payment session."); + setGatewayError(result.error || t("failedToCreateSession")); } } catch { if (requestId !== sessionRequestIdRef.current) return; - setGatewayError("Failed to initialize payment. Please try again."); + setGatewayError(t("failedToInitPayment")); } finally { if (requestId === sessionRequestIdRef.current) { setLoading(false); } } }, - [sessionPaymentMethod, cart.id], + [sessionPaymentMethod, cart.id, t], ); // Track the cart total so we can recreate the session when it changes @@ -247,10 +250,10 @@ export const PaymentSection = forwardRef< () => ({ submit: async () => { if (!paymentSessionId || !clientSecret) { - return { error: "Payment not ready" }; + return { error: t("failedToInitPayment") }; } if (!selectedCardId && !gatewayHandleRef.current) { - return { error: "Payment not ready" }; + return { error: t("failedToInitPayment") }; } setProcessing(true); @@ -267,7 +270,7 @@ export const PaymentSection = forwardRef< if (!addressSuccess) { setProcessing(false); - return { error: "Failed to save billing address" }; + return { error: t("failedToSaveBilling") }; } // 2. Confirm payment with gateway @@ -302,7 +305,7 @@ export const PaymentSection = forwardRef< await onPaymentComplete(paymentSessionId); return {}; } catch { - const msg = "An error occurred during payment. Please try again."; + const msg = t("paymentError"); setGatewayError(msg); setProcessing(false); return { error: msg }; @@ -320,6 +323,7 @@ export const PaymentSection = forwardRef< onPaymentComplete, cart.id, setProcessing, + t, ], ); @@ -328,9 +332,9 @@ export const PaymentSection = forwardRef< return (
    {/* Section Header */} -

    Payment

    +

    {t("paymentMethod")}

    - All transactions are secure and encrypted. + {t("secureTransactions")}

    {/* Inline requirement errors from parent */} @@ -373,14 +377,15 @@ export const PaymentSection = forwardRef< width={34} /> - {getCardLabel(card.cc_type)} ending in {card.last_digits} + {getCardLabel(card.cc_type)} {t("endingIn")}{" "} + {card.last_digits} - Exp {String(card.month).padStart(2, "0")}/{card.year} + {t("exp")} {String(card.month).padStart(2, "0")}/{card.year} {card.default && ( - Default + {t("default")} )} @@ -395,7 +400,7 @@ export const PaymentSection = forwardRef< - Add new payment method + {t("addNewPaymentMethod")} @@ -407,7 +412,7 @@ export const PaymentSection = forwardRef<
    - Credit card + {t("creditCard")}
    @@ -419,7 +424,7 @@ export const PaymentSection = forwardRef<
    - Loading payment form... + {t("loadingPaymentForm")}
    )} @@ -451,9 +456,7 @@ export const PaymentSection = forwardRef< className="w-10 h-10 text-gray-300 mx-auto mb-3" strokeWidth={1.5} /> -

    - No payment methods available for this cart. -

    +

    {t("noPaymentMethods")}

    )}
    @@ -468,9 +471,7 @@ export const PaymentSection = forwardRef< handleUseShippingChange(checked === true) } /> - - Use shipping address as billing address - + {t("sameAsShipping")} {!useShippingForBilling && ( diff --git a/src/components/checkout/ShippingMethodSection.tsx b/src/components/checkout/ShippingMethodSection.tsx index c7c8397c..a00af1c0 100644 --- a/src/components/checkout/ShippingMethodSection.tsx +++ b/src/components/checkout/ShippingMethodSection.tsx @@ -1,6 +1,7 @@ "use client"; import type { Shipment } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; interface ShippingMethodSectionProps { @@ -16,9 +17,13 @@ export function ShippingMethodSection({ processing, errors, }: ShippingMethodSectionProps) { + const t = useTranslations("checkout"); + return (
    -

    Shipping method

    +

    + {t("shippingMethod")} +

    {errors && errors.length > 0 && (
    @@ -32,7 +37,7 @@ export function ShippingMethodSection({ {shipments.length === 0 ? (
    - Enter your shipping address to view available shipping methods. + {t("enterShippingAddressForMethods")}
    ) : (
    @@ -44,11 +49,17 @@ export function ShippingMethodSection({
    {shipments.length > 1 && (

    - Shipment {index + 1} of {shipments.length} + {t("shipmentOf", { + current: index + 1, + total: shipments.length, + })} {shipment.stock_location?.name && ( {" "} - — Ships from {shipment.stock_location.name} + —{" "} + {t("shipsFrom", { + location: shipment.stock_location.name, + })} )}

    diff --git a/src/components/checkout/Summary.tsx b/src/components/checkout/Summary.tsx index 92d47cf0..c0e20ea8 100644 --- a/src/components/checkout/Summary.tsx +++ b/src/components/checkout/Summary.tsx @@ -1,6 +1,7 @@ "use client"; import type { Cart } from "@spree/sdk"; +import { useTranslations } from "next-intl"; import { ProductImage } from "@/components/ui/product-image"; interface SummaryProps { @@ -8,6 +9,8 @@ interface SummaryProps { } export function Summary({ cart }: SummaryProps) { + const tc = useTranslations("common"); + const t = useTranslations("checkout"); const items = cart.items || []; const hasShipping = (cart.shipments?.length ?? 0) > 0; @@ -50,38 +53,40 @@ export function Summary({ cart }: SummaryProps) { {/* Totals — Shopify style */}
    - Subtotal + {tc("subtotal")} {cart.display_item_total}
    - Shipping + {tc("shipping")} {hasShipping ? ( {cart.display_ship_total} ) : ( - Enter shipping address + {t("enterShippingAddress")} )}
    {parseFloat(cart.promo_total) !== 0 && (
    - Discount + {tc("discount")} {cart.display_promo_total}
    )} {parseFloat(cart.tax_total) > 0 && (
    - Tax + {tc("tax")} {cart.display_tax_total}
    )} {/* Total row — Shopify: bold, larger, with currency code */}
    - Total + + {tc("total")} +
    {cart.currency} From 61ba21cc1774bf2d80e2ab6dfc57b90492db6d75 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 16 Mar 2026 11:20:07 +0100 Subject: [PATCH 8/8] Fix confirm-payment test: add next-intl mock for useTranslations The test was missing a next-intl mock after i18n was added to the confirm-payment page. Uses the same identity-function mock pattern as ProductCard tests. Co-Authored-By: Claude Opus 4.6 --- .../(checkout)/confirm-payment/__tests__/page.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx index 5f95645a..9d6f1f1e 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx +++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx @@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockReplace = vi.fn(); let mockSearchParams = new URLSearchParams(); +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), @@ -56,7 +60,7 @@ describe("ConfirmPaymentPage", () => { renderPage(); }); - expect(screen.getByText("Confirming your payment...")).toBeInTheDocument(); + expect(screen.getByText("confirmingPayment")).toBeInTheDocument(); }); it("redirects to order-placed on success", async () => { @@ -123,9 +127,7 @@ describe("ConfirmPaymentPage", () => { await waitFor(() => { expect(mockReplace).toHaveBeenCalledWith( - expect.stringContaining( - "payment_error=Payment%20could%20not%20be%20confirmed", - ), + expect.stringContaining("payment_error=paymentError"), ); }); });