Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/technical-debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ Dług dotyczący całego systemu (backend, deploy, dane) żyje w `sklepik/docs/r

## Dług techniczny

### 2026-07-07 — CountrySwitcher zastąpiony LanguageSwitcher — Market switcher jeszcze nie istnieje

**Status:** w toku (krok 0+1 planu zamknięte, kroki 2-4 czekają na realny drugi rynek)

**Skrót:** `CountrySwitcher.tsx` mieszał język i walutę w jednym dropdownie i linkował wg usuniętego schematu URL `/{country}/{locale}/...` → 404 na wyborze kraju + wizualny duplikat "PL PL | PLN". Usunięty (razem z `useCountrySwitch.ts`) i zastąpiony `LanguageSwitcher.tsx` — czysto językowym przełącznikiem (pl/en dziś, de/es/fr gotowe strukturalnie w `messages/*.json`, ale niewystawione dopóki ktoś nie zweryfikuje ich jakości), niezależnym od waluty/rynku. Ten sam fix zastosowany w `MobileMenu.tsx` (panel "country" → "language").

**Co trzeba zrobić:** zbudować `MarketSwitcher` (waluta + wysyłka, oparty o cookie, nie URL) — dopiero gdy w adminie powstanie realny drugi `Market` (np. Eurozone/EUR). `updateCartMarket` (`src/lib/data/checkout.ts`) zostawiony nieużywany celowo — gotowa, przetestowana funkcja do reużycia w tym kroku.

**Warunek zamknięcia:** pełny plan w `sklepik/docs/plans/market-language-switcher.md` zrealizowany (kroki 2-4).

### 2026-07-07 — Zdjęcie główne na stronie produktu czasem się nie wyświetla (mitygacja, nie fix)

**Status:** otwarte (frontowa mitygacja wdrożona, przyczyna po stronie backendu/infra pozostaje)
Expand Down
2 changes: 2 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"continueShopping": "Weiter einkaufen",
"orderSummary": "Bestellübersicht",
"selectCountry": "Land auswählen",
"selectLanguage": "Sprache auswählen",
"myAccount": "Mein Konto",
"giftCard": "Geschenkkarte",
"storeCredit": "Guthaben",
Expand All @@ -31,6 +32,7 @@
"openMenu": "Menü öffnen",
"menu": "Menü",
"selectCountry": "Land auswählen",
"selectLanguage": "Sprache auswählen",
"home": "Startseite",
"allProducts": "Alle Produkte",
"contact": "Kontakt",
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"continueShopping": "Continue Shopping",
"orderSummary": "Order Summary",
"selectCountry": "Select Country",
"selectLanguage": "Select Language",
"myAccount": "My Account",
"giftCard": "Gift card",
"storeCredit": "Store credit",
Expand All @@ -31,6 +32,7 @@
"openMenu": "Open menu",
"menu": "Menu",
"selectCountry": "Select Country",
"selectLanguage": "Select Language",
"home": "Home",
"allProducts": "All Products",
"contact": "Contact",
Expand Down
2 changes: 2 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"continueShopping": "Seguir comprando",
"orderSummary": "Resumen del pedido",
"selectCountry": "Seleccionar pais",
"selectLanguage": "Seleccionar idioma",
"myAccount": "Mi cuenta",
"giftCard": "Tarjeta regalo",
"storeCredit": "Credito de la tienda",
Expand All @@ -31,6 +32,7 @@
"openMenu": "Abrir menu",
"menu": "Menu",
"selectCountry": "Seleccionar pais",
"selectLanguage": "Seleccionar idioma",
"home": "Inicio",
"allProducts": "Todos los productos",
"contact": "Contacto",
Expand Down
2 changes: 2 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"continueShopping": "Continuer les achats",
"orderSummary": "Recapitulatif de la commande",
"selectCountry": "Choisir le pays",
"selectLanguage": "Choisir la langue",
"myAccount": "Mon compte",
"giftCard": "Carte cadeau",
"storeCredit": "Credit boutique",
Expand All @@ -31,6 +32,7 @@
"openMenu": "Ouvrir le menu",
"menu": "Menu",
"selectCountry": "Choisir le pays",
"selectLanguage": "Choisir la langue",
"home": "Accueil",
"allProducts": "Tous les produits",
"contact": "Contact",
Expand Down
2 changes: 2 additions & 0 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"continueShopping": "Kontynuuj zakupy",
"orderSummary": "Podsumowanie zamówienia",
"selectCountry": "Wybierz kraj",
"selectLanguage": "Wybierz język",
"myAccount": "Moje konto",
"giftCard": "Karta podarunkowa",
"storeCredit": "Kredyt sklepowy",
Expand All @@ -31,6 +32,7 @@
"openMenu": "Otwórz menu",
"menu": "Menu",
"selectCountry": "Wybierz kraj",
"selectLanguage": "Wybierz język",
"home": "Strona główna",
"allProducts": "Wszystkie produkty",
"contact": "Kontakt",
Expand Down
8 changes: 4 additions & 4 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ const LazyMobileMenu = dynamic(
},
);

const LazyCountrySwitcher = dynamic(
const LazyLanguageSwitcher = dynamic(
() =>
import("@/components/layout/CountrySwitcher").then((mod) => ({
default: mod.CountrySwitcher,
import("@/components/layout/LanguageSwitcher").then((mod) => ({
default: mod.LanguageSwitcher,
})),
{
loading: () => (
Expand Down Expand Up @@ -64,7 +64,7 @@ export async function Header({
}
rightStart={
<div className="hidden lg:block">
<LazyCountrySwitcher />
<LazyLanguageSwitcher />
</div>
}
rightEnd={
Expand Down
76 changes: 76 additions & 0 deletions src/components/layout/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import { Check, ChevronDown, Globe } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getDefaultLocale, getPrefixedLocales } from "@/lib/store";
import { LANGUAGE_NAMES } from "@/lib/utils/language-names";
import { buildBasePath, getPathWithoutPrefix } from "@/lib/utils/path";

/**
* Purely a UI-language control — independent of currency/market (see
* docs/plans/market-language-switcher.md). Rewrites only the /{locale}
* URL segment via a soft client navigation; never touches cart, currency,
* or cookies.
*/
export function LanguageSwitcher() {
const currentLocale = useLocale();
const pathname = usePathname();
const router = useRouter();
const t = useTranslations("common");

const locales = [
getDefaultLocale() as Locale,
...getPrefixedLocales().map((l) => l as Locale),
];

// Nothing to switch between — don't render a dropdown with one option.
if (locales.length <= 1) return null;

const handleSelect = (locale: Locale) => {
if (locale === currentLocale) return;
const pathRest = getPathWithoutPrefix(pathname);
router.push(`${buildBasePath(locale)}${pathRest}`);
};

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Globe className="w-4 h-4 text-gray-400" />
<span className="font-medium">
{LANGUAGE_NAMES[currentLocale as Locale] ??
currentLocale.toUpperCase()}
</span>
<ChevronDown className="w-4 h-4 text-gray-400" />
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{t("selectLanguage")}</DropdownMenuLabel>
{locales.map((locale) => {
const isSelected = locale === currentLocale;
return (
<DropdownMenuItem
key={locale}
onSelect={() => handleSelect(locale)}
>
<span className="flex-1 font-medium">
{LANGUAGE_NAMES[locale] ?? locale.toUpperCase()}
</span>
{isSelected && <Check className="w-4 h-4" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
95 changes: 46 additions & 49 deletions src/components/layout/MobileMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import type { Category } from "@spree/sdk";
import { ArrowLeft, Check, ChevronRight, User, X } from "lucide-react";
import { ArrowLeft, Check, ChevronRight, Globe, User, X } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
Expand All @@ -14,22 +15,14 @@ import {
SheetFooter,
SheetTitle,
} from "@/components/ui/sheet";
import { useStore } from "@/contexts/StoreContext";
import { useCountrySwitch } from "@/hooks/useCountrySwitch";

// Convert ISO country code to flag emoji
function countryToFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length !== 2) return "";
const firstChar = code.charCodeAt(0) - 65 + 0x1f1e6;
const secondChar = code.charCodeAt(1) - 65 + 0x1f1e6;
return String.fromCodePoint(firstChar, secondChar);
}
import { getDefaultLocale, getPrefixedLocales } from "@/lib/store";
import { LANGUAGE_NAMES } from "@/lib/utils/language-names";
import { buildBasePath, getPathWithoutPrefix } from "@/lib/utils/path";

type PanelType =
| { kind: "main" }
| { kind: "category"; category: Category }
| { kind: "country" };
| { kind: "language" };

interface MobileMenuProps {
rootCategories: Category[];
Expand All @@ -46,11 +39,20 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
const rafRef = useRef<number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

const { country, currency, countries } = useStore();
const { isCountryNavigating, handleCountrySelect } = useCountrySwitch({
currentCountry: country,
onBeforeNavigate: () => setOpen(false),
});
const currentLocale = useLocale();
const pathname = usePathname();
const router = useRouter();
const locales = [
getDefaultLocale() as Locale,
...getPrefixedLocales().map((l) => l as Locale),
];

const handleLanguageSelect = (locale: Locale) => {
setOpen(false);
if (locale === currentLocale) return;
const pathRest = getPathWithoutPrefix(pathname);
router.push(`${buildBasePath(locale)}${pathRest}`);
};

const currentPanel = panelStack[panelStack.length - 1];

Expand Down Expand Up @@ -191,8 +193,8 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
<span>
{currentPanel.kind === "category"
? currentPanel.category.name
: currentPanel.kind === "country"
? t("selectCountry")
: currentPanel.kind === "language"
? t("selectLanguage")
: ""}
</span>
</button>
Expand Down Expand Up @@ -262,21 +264,22 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
</Link>
</nav>

{/* Footer: Country switcher (mobile + tablet) + Account (mobile only) */}
{/* Footer: Language switcher (mobile + tablet) + Account (mobile only) */}
<SheetFooter className="lg:hidden border-t border-gray-200 pt-4 gap-2">
<button
type="button"
onClick={() => pushPanel({ kind: "country" })}
className="flex items-center gap-2 px-4 py-2.5 text-base text-gray-700 hover:bg-gray-50 rounded-lg transition-colors w-full"
>
<span className="text-lg leading-none">
{countryToFlag(country)}
</span>
<span className="font-medium">{country.toUpperCase()}</span>
<span className="text-gray-400">|</span>
<span>{currency}</span>
<ChevronRight className="w-4 h-4 text-gray-400 ml-auto" />
</button>
{locales.length > 1 && (
<button
type="button"
onClick={() => pushPanel({ kind: "language" })}
className="flex items-center gap-2 px-4 py-2.5 text-base text-gray-700 hover:bg-gray-50 rounded-lg transition-colors w-full"
>
<Globe className="w-4 h-4 text-gray-400" />
<span className="font-medium">
{LANGUAGE_NAMES[currentLocale as Locale] ??
currentLocale.toUpperCase()}
</span>
<ChevronRight className="w-4 h-4 text-gray-400 ml-auto" />
</button>
)}

<SheetClose asChild className="md:hidden">
<Link
Expand Down Expand Up @@ -358,10 +361,10 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
);
})}

{/* Country selector panel */}
{/* Language selector panel */}
<div
className={`absolute inset-0 flex flex-col bg-white transition-transform duration-300 ease-in-out ${
currentPanel.kind === "country" &&
currentPanel.kind === "language" &&
animatedIndex === panelStack.length - 1
? "translate-x-0"
: "translate-x-full"
Expand All @@ -374,33 +377,27 @@ export function MobileMenu({ rootCategories, basePath }: MobileMenuProps) {
className="flex items-center gap-2 text-gray-700 hover:text-gray-900 py-2 text-base font-medium"
>
<ArrowLeft className="w-5 h-5" />
<span>{t("selectCountry")}</span>
<span>{t("selectLanguage")}</span>
</button>
</div>

<div className="flex-1 overflow-y-auto px-4 py-2">
{countries.map((c) => {
const isSelected =
c.iso.toLowerCase() === country.toLowerCase();
{locales.map((locale) => {
const isSelected = locale === currentLocale;
return (
<button
key={c.iso}
key={locale}
type="button"
disabled={isCountryNavigating}
onClick={() => handleCountrySelect(c)}
onClick={() => handleLanguageSelect(locale)}
className={`w-full flex items-center gap-3 px-3 py-3 rounded-lg text-base transition-colors ${
isSelected
? "bg-gray-100 font-medium"
: "hover:bg-gray-50"
}`}
>
<span className="text-lg leading-none">
{countryToFlag(c.iso)}
</span>
<span className="flex-1 text-left font-medium">
{c.name}
{LANGUAGE_NAMES[locale] ?? locale.toUpperCase()}
</span>
<span className="text-sm text-gray-500">{c.currency}</span>
{isSelected && <Check className="w-4 h-4 text-black" />}
</button>
);
Expand Down
10 changes: 10 additions & 0 deletions src/lib/utils/language-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Each language's own name in its own language (endonym) — a language
// switcher should stay legible regardless of which UI language is
// currently active, so labels are never run through next-intl.
export const LANGUAGE_NAMES: Record<Locale, string> = {
pl: "Polski",
en: "English",
de: "Deutsch",
es: "Español",
fr: "Français",
};