From 92b6c830e1713ecba28e91074358bf91fac514a6 Mon Sep 17 00:00:00 2001 From: Unchained Date: Mon, 23 Mar 2026 20:59:33 +0200 Subject: [PATCH] feat: implement locale-aware routing with [locale] dynamic segments WARNING: This change breaks existing SEO URLs for Serbian locale. Changes: - Migrated from separate locale folders (src/app/en/, src/app/de/, etc.) to [locale] dynamic segments (src/app/[locale]/) - Serbian is now at /sr/ instead of / (root) - English at /en/, German at /de/, French at /fr/ - All components updated to generate locale-aware links - Root / now redirects to /sr (307 temporary redirect) SEO Impact: - Previously indexed Serbian URLs (/, /products, /about, /contact) will now return 404 or redirect to /sr/* URLs - This is a breaking change for SEO - Serbian pages should ideally remain at root (/) with only non-default locales getting prefix - Consider implementing 301 redirects from old URLs to maintain search engine rankings Technical Notes: - next-intl v4 with [locale] structure requires ALL locales to have the prefix (cannot have default locale at root) - Alternative approach would be separate folder structure per locale --- middleware.ts | 1 - src/app/{ => [locale]}/about/page.tsx | 29 +- src/app/[locale]/checkout/page.tsx | 384 ++++++++++++++ src/app/{en => [locale]}/contact/page.tsx | 9 +- src/app/[locale]/layout.tsx | 25 + src/app/{en => [locale]}/page.tsx | 41 +- .../{ => [locale]}/products/[slug]/page.tsx | 60 ++- src/app/{ => [locale]}/products/page.tsx | 32 +- src/app/checkout/page.tsx | 495 ------------------ src/app/contact/page.tsx | 191 ------- src/app/de/about/page.tsx | 110 ---- src/app/de/contact/page.tsx | 128 ----- src/app/de/page.tsx | 213 -------- src/app/de/products/[slug]/page.tsx | 90 ---- src/app/de/products/page.tsx | 87 --- src/app/en/about/page.tsx | 117 ----- src/app/en/products/[slug]/page.tsx | 102 ---- src/app/en/products/page.tsx | 91 ---- src/app/fr/about/page.tsx | 48 -- src/app/fr/contact/page.tsx | 89 ---- src/app/fr/page.tsx | 117 ----- src/app/fr/products/[slug]/page.tsx | 83 --- src/app/fr/products/page.tsx | 63 --- src/app/layout.tsx | 17 +- src/app/page.tsx | 218 +------- src/components/cart/CartDrawer.tsx | 95 ++-- src/components/home/AsSeenIn.tsx | 20 +- src/components/home/BeforeAfterGallery.tsx | 44 +- src/components/home/Hero.tsx | 4 +- src/components/home/HeroVideo.tsx | 11 +- src/components/home/HowItWorks.tsx | 79 ++- src/components/home/NewHero.tsx | 8 +- src/components/home/NewsletterSection.tsx | 21 +- src/components/home/ProblemSection.tsx | 61 +-- src/components/home/TrustBadges.tsx | 167 +++--- src/components/layout/Footer.tsx | 77 ++- src/components/layout/Header.tsx | 48 +- src/components/product/ProductBenefits.tsx | 95 ++-- src/components/product/ProductCard.tsx | 28 +- src/components/product/ProductDetail.tsx | 155 ++---- src/components/product/ProductReviews.tsx | 87 +-- src/i18n/messages/de.json | 358 +++++++++++++ src/i18n/messages/en.json | 247 +++++++++ src/i18n/messages/fr.json | 358 +++++++++++++ src/i18n/messages/sr.json | 247 +++++++++ src/i18n/request.tsx | 4 +- src/i18n/routing.ts | 2 +- 47 files changed, 2175 insertions(+), 2881 deletions(-) rename src/app/{ => [locale]}/about/page.tsx (82%) create mode 100644 src/app/[locale]/checkout/page.tsx rename src/app/{en => [locale]}/contact/page.tsx (98%) create mode 100644 src/app/[locale]/layout.tsx rename src/app/{en => [locale]}/page.tsx (88%) rename src/app/{ => [locale]}/products/[slug]/page.tsx (56%) rename src/app/{ => [locale]}/products/page.tsx (77%) delete mode 100644 src/app/checkout/page.tsx delete mode 100644 src/app/contact/page.tsx delete mode 100644 src/app/de/about/page.tsx delete mode 100644 src/app/de/contact/page.tsx delete mode 100644 src/app/de/page.tsx delete mode 100644 src/app/de/products/[slug]/page.tsx delete mode 100644 src/app/de/products/page.tsx delete mode 100644 src/app/en/about/page.tsx delete mode 100644 src/app/en/products/[slug]/page.tsx delete mode 100644 src/app/en/products/page.tsx delete mode 100644 src/app/fr/about/page.tsx delete mode 100644 src/app/fr/contact/page.tsx delete mode 100644 src/app/fr/page.tsx delete mode 100644 src/app/fr/products/[slug]/page.tsx delete mode 100644 src/app/fr/products/page.tsx create mode 100644 src/i18n/messages/de.json create mode 100644 src/i18n/messages/fr.json diff --git a/middleware.ts b/middleware.ts index f3c0ac5..5fcccb0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,7 +3,6 @@ import { routing } from "./src/i18n/routing"; export default createMiddleware({ ...routing, - localePrefix: "as-needed", }); export const config = { diff --git a/src/app/about/page.tsx b/src/app/[locale]/about/page.tsx similarity index 82% rename from src/app/about/page.tsx rename to src/app/[locale]/about/page.tsx index bd195bc..ae786ab 100644 --- a/src/app/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -1,20 +1,31 @@ -import { getTranslations } from "next-intl/server"; +import { getTranslations, setRequestLocale } from "next-intl/server"; import Header from "@/components/layout/Header"; import Footer from "@/components/layout/Footer"; -export async function generateMetadata() { +interface AboutPageProps { + params: Promise<{ locale: string }>; +} + +export async function generateMetadata({ params }: AboutPageProps) { + const { locale } = await params; return { - title: "O nama - ManoonOils", - description: "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti.", + title: locale === "sr" + ? "O nama - ManoonOils" + : "About - ManoonOils", + description: locale === "sr" + ? "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti." + : "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.", }; } -export default async function AboutPage() { +export default async function AboutPage({ params }: AboutPageProps) { + const { locale } = await params; + setRequestLocale(locale); const t = await getTranslations("About"); return ( <> -
+
@@ -32,7 +43,7 @@ export default async function AboutPage() {
Proizvodnja prirodnih ulja
@@ -110,8 +121,8 @@ export default async function AboutPage() {
-
+
); -} +} \ No newline at end of file diff --git a/src/app/[locale]/checkout/page.tsx b/src/app/[locale]/checkout/page.tsx new file mode 100644 index 0000000..957b3f3 --- /dev/null +++ b/src/app/[locale]/checkout/page.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import { useTranslations, useLocale } from "next-intl"; +import Header from "@/components/layout/Header"; +import Footer from "@/components/layout/Footer"; +import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; +import { formatPrice } from "@/lib/saleor"; +import { saleorClient } from "@/lib/saleor/client"; +import { + CHECKOUT_SHIPPING_ADDRESS_UPDATE, + CHECKOUT_BILLING_ADDRESS_UPDATE, + CHECKOUT_COMPLETE, +} from "@/lib/saleor/mutations/Checkout"; +import type { Checkout } from "@/types/saleor"; + +interface ShippingAddressUpdateResponse { + checkoutShippingAddressUpdate?: { + checkout?: Checkout; + errors?: Array<{ message: string }>; + }; +} + +interface BillingAddressUpdateResponse { + checkoutBillingAddressUpdate?: { + checkout?: Checkout; + errors?: Array<{ message: string }>; + }; +} + +interface CheckoutCompleteResponse { + checkoutComplete?: { + order?: { number: string }; + errors?: Array<{ message: string }>; + }; +} + +interface AddressForm { + firstName: string; + lastName: string; + streetAddress1: string; + streetAddress2: string; + city: string; + postalCode: string; + phone: string; +} + +export default function CheckoutPage() { + const t = useTranslations("Checkout"); + const locale = useLocale(); + const router = useRouter(); + const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [orderComplete, setOrderComplete] = useState(false); + const [orderNumber, setOrderNumber] = useState(null); + + const [sameAsShipping, setSameAsShipping] = useState(true); + const [shippingAddress, setShippingAddress] = useState({ + firstName: "", + lastName: "", + streetAddress1: "", + streetAddress2: "", + city: "", + postalCode: "", + phone: "", + }); + const [billingAddress, setBillingAddress] = useState({ + firstName: "", + lastName: "", + streetAddress1: "", + streetAddress2: "", + city: "", + postalCode: "", + phone: "", + }); + + const lines = getLines(); + const total = getTotal(); + + useEffect(() => { + if (!checkout) { + refreshCheckout(); + } + }, [checkout, refreshCheckout]); + + const handleShippingChange = (field: keyof AddressForm, value: string) => { + setShippingAddress((prev) => ({ ...prev, [field]: value })); + if (sameAsShipping) { + setBillingAddress((prev) => ({ ...prev, [field]: value })); + } + }; + + const handleBillingChange = (field: keyof AddressForm, value: string) => { + setBillingAddress((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!checkout) { + setError(t("errorNoCheckout")); + return; + } + + setIsLoading(true); + setError(null); + + try { + const shippingResult = await saleorClient.mutate({ + mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE, + variables: { + checkoutId: checkout.id, + shippingAddress: { + ...shippingAddress, + country: "RS", + }, + }, + }); + + if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) { + throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message); + } + + const billingResult = await saleorClient.mutate({ + mutation: CHECKOUT_BILLING_ADDRESS_UPDATE, + variables: { + checkoutId: checkout.id, + billingAddress: { + ...billingAddress, + country: "RS", + }, + }, + }); + + if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) { + throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message); + } + + const completeResult = await saleorClient.mutate({ + mutation: CHECKOUT_COMPLETE, + variables: { + checkoutId: checkout.id, + }, + }); + + if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) { + throw new Error(completeResult.data.checkoutComplete.errors[0].message); + } + + const order = completeResult.data?.checkoutComplete?.order; + if (order) { + setOrderNumber(order.number); + setOrderComplete(true); + } else { + throw new Error(t("errorCreatingOrder")); + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : null; + setError(errorMessage || t("errorOccurred")); + } finally { + setIsLoading(false); + } + }; + + if (orderComplete) { + return ( + <> +
+
+
+
+
+
+ + + +
+

{t("orderConfirmed")}

+

{t("thankYou")}

+
+ + {orderNumber && ( +
+

{t("orderNumber")}

+

#{orderNumber}

+
+ )} + +

+ {t("confirmationEmail")} +

+ + + {t("continueShoppingBtn")} + +
+
+
+
+
+
+ + ); + } + + return ( + <> +
+
+
+
+

{t("checkout")}

+ + {error && ( +
+ {error} +
+ )} + +
+
+
+
+

{t("shippingAddress")}

+
+
+ + handleShippingChange("firstName", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ + handleShippingChange("lastName", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ + handleShippingChange("streetAddress1", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ handleShippingChange("streetAddress2", e.target.value)} + placeholder={t("streetAddressOptional")} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ + handleShippingChange("city", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ + handleShippingChange("postalCode", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+ + handleShippingChange("phone", e.target.value)} + className="w-full border border-border px-4 py-2 rounded" + /> +
+
+
+ +
+ +
+ + +
+
+ +
+

{t("orderSummary")}

+ + {lines.length === 0 ? ( +

{t("yourCartEmpty")}

+ ) : ( + <> +
+ {lines.map((line) => ( +
+
+ {line.variant.product.media[0]?.url && ( + {line.variant.product.name} + )} +
+
+

{line.variant.product.name}

+

+ {t("qty")}: {line.quantity} +

+

+ {formatPrice(line.totalPrice.gross.amount)} +

+
+
+ ))} +
+ +
+
+ {t("subtotal")} + {formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)} +
+
+ {t("total")} + {formatPrice(total)} +
+
+ + )} +
+
+
+
+
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/src/app/en/contact/page.tsx b/src/app/[locale]/contact/page.tsx similarity index 98% rename from src/app/en/contact/page.tsx rename to src/app/[locale]/contact/page.tsx index 05d5a95..7ed256a 100644 --- a/src/app/en/contact/page.tsx +++ b/src/app/[locale]/contact/page.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import Header from "@/components/layout/Header"; import Footer from "@/components/layout/Footer"; import { Mail, MapPin, Truck, Check } from "lucide-react"; export default function ContactPage() { const t = useTranslations("Contact"); + const locale = useLocale(); const [formData, setFormData] = useState({ name: "", email: "", @@ -22,7 +23,7 @@ export default function ContactPage() { return ( <> -
+
@@ -184,8 +185,8 @@ export default function ContactPage() {
-
+
); -} +} \ No newline at end of file diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..e6b0a8a --- /dev/null +++ b/src/app/[locale]/layout.tsx @@ -0,0 +1,25 @@ +import { NextIntlClientProvider } from "next-intl"; +import { getMessages, setRequestLocale } from "next-intl/server"; +import { routing } from "@/i18n/routing"; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + const messages = await getMessages(); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/en/page.tsx b/src/app/[locale]/page.tsx similarity index 88% rename from src/app/en/page.tsx rename to src/app/[locale]/page.tsx index d22fbe0..7086bee 100644 --- a/src/app/en/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,5 +1,5 @@ import { getProducts } from "@/lib/saleor"; -import { getTranslations } from "next-intl/server"; +import { getTranslations, setRequestLocale } from "next-intl/server"; import Header from "@/components/layout/Header"; import Footer from "@/components/layout/Footer"; import HeroVideo from "@/components/home/HeroVideo"; @@ -11,20 +11,29 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery"; import ProblemSection from "@/components/home/ProblemSection"; import HowItWorks from "@/components/home/HowItWorks"; -export async function generateMetadata() { +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); return { - title: "ManoonOils - Premium Natural Oils for Hair & Skin", - description: "Discover our premium collection of natural oils for hair and skin care.", + title: locale === "sr" + ? "ManoonOils - Premium prirodna ulja za negu kose i kože" + : "ManoonOils - Premium Natural Oils for Hair & Skin", + description: locale === "sr" + ? "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože." + : "Discover our premium collection of natural oils for hair and skin care.", }; } -export default async function EnglishHomepage() { +export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); const t = await getTranslations("Home"); const tBenefits = await getTranslations("Benefits"); - + + const productLocale = locale === "sr" ? "SR" : "EN"; let products: any[] = []; try { - products = await getProducts("UK"); + products = await getProducts(productLocale); } catch (e) { console.log("Failed to fetch products during build"); } @@ -32,12 +41,14 @@ export default async function EnglishHomepage() { const featuredProducts = products?.slice(0, 4) || []; const hasProducts = featuredProducts.length > 0; + const basePath = `/${locale}`; + return ( <> -
+
- + @@ -67,13 +78,13 @@ export default async function EnglishHomepage() {
{featuredProducts.map((product, index) => ( - + ))}
-