From 13f81cfdb8b9829bc54bee639917cb91bd5a8508 Mon Sep 17 00:00:00 2001 From: Trevor Nederlof Date: Fri, 3 Oct 2025 16:48:25 -0400 Subject: [PATCH] Add multi currency support --- AGENTS.md | 4 +- README.md | 16 + backend/AGENTS.md | 33 +- .../50f63877083c_add_fx_rates_table.py | 35 + backend/app/currency.py | 126 ++ backend/app/db.py | 10 +- backend/app/main.py | 6 + backend/store.db | Bin 3940352 -> 3985408 bytes backend/tests/api/test_currency.py | 187 +++ backend/tests/conftest.py | 1 + frontend/AGENTS.md | 54 +- frontend/e2e/tests/currency.spec.ts | 172 +++ frontend/package.json | 10 +- frontend/src/App.tsx | 33 +- frontend/src/components/CartItem/CartItem.tsx | 9 +- .../components/CartItem/CartItemMobile.tsx | 9 +- .../src/components/CurrencySelector.test.tsx | 114 ++ frontend/src/components/CurrencySelector.tsx | 107 ++ .../Delivery/DeliveryOptionsSelector.tsx | 11 +- .../Delivery/DeliveryOptionsSummary.tsx | 11 +- frontend/src/components/FeaturedBanner.tsx | 4 +- frontend/src/components/Header.tsx | 4 + frontend/src/components/ProductCard.tsx | 4 +- frontend/src/context/CurrencyContext.tsx | 208 +++ frontend/src/context/index.ts | 6 + frontend/src/hooks/useCurrency.ts | 12 + frontend/src/pages/Cart.tsx | 9 +- frontend/src/pages/Product.tsx | 4 +- frontend/src/test/setup.ts | 9 + frontend/src/utils/currency.test.ts | 254 ++++ frontend/vitest.config.ts | 12 + package-lock.json | 1349 ++++++++++++++++- 32 files changed, 2764 insertions(+), 59 deletions(-) create mode 100644 backend/alembic/versions/50f63877083c_add_fx_rates_table.py create mode 100644 backend/app/currency.py create mode 100644 backend/tests/api/test_currency.py create mode 100644 frontend/e2e/tests/currency.spec.ts create mode 100644 frontend/src/components/CurrencySelector.test.tsx create mode 100644 frontend/src/components/CurrencySelector.tsx create mode 100644 frontend/src/context/CurrencyContext.tsx create mode 100644 frontend/src/context/index.ts create mode 100644 frontend/src/hooks/useCurrency.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/utils/currency.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 37036da..70994cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,8 @@ **Architecture:** FastAPI (Python 3.13+) backend + React TypeScript frontend **Stack:** SQLite + Alembic, Native hot-reload, E2E testing with Playwright **Design System:** Monochrome palette (sand/ink/charcoal) with subtle slate blue accents -**URLs:** Frontend http://localhost:3001, Backend http://localhost:8001/docs +**URLs:** Frontend http://localhost:3001, Backend http://localhost:8001/docs +**Features:** Multi-currency (USD/GBP/EUR/AUD/MXN/JPY), live FX rates, delivery options ## Essential Commands @@ -160,6 +161,7 @@ def process_payment(payment_data: dict) -> Result: ### Frontend Architecture - **Components:** Functional components only, TypeScript interfaces - **State Management:** Context API for global state, local state with useState/useReducer +- **Currency System:** CurrencyContext manages FX rates, conversion, formatting (6 currencies) - **Forms:** Formik + Yup for validation - **UI:** Chakra UI components - **Data Flow:** API calls → context state → component props diff --git a/README.md b/README.md index 582ac40..ae2a6f6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ Follow the [Quick Start](#quick-start) guide to get started. Once the front and See the [DEMO.md](DEMO.md) for more information about how to effectively use this repo for an array of different demo purposes ranging from issue to PR to advanced feature adds. +## Features + +- **Full-stack E-commerce:** Product catalog, cart, delivery options +- **Multi-Currency Support:** USD, GBP, EUR, AUD, MXN, JPY with live FX rates +- **Image Management:** BLOB storage with SQLite, efficient retrieval +- **Delivery Options:** Configurable shipping with cost calculations +- **Modern Stack:** FastAPI + React TypeScript + SQLite +- **Test-Driven:** 100% coverage requirement, Playwright E2E tests + ## Project Structure ``` @@ -19,6 +28,10 @@ See the [DEMO.md](DEMO.md) for more information about how to effectively use thi ├── .github/ # GitHub workflows and CI configuration ├── backend/ # FastAPI backend │ ├── app/ # Application source code +│ │ ├── currency.py # FX rates service and models +│ │ ├── models.py # SQLModel database models +│ │ ├── schemas.py # Pydantic request/response schemas +│ │ └── main.py # FastAPI routes and app │ ├── tests/ # Backend tests │ ├── alembic/ # Database migrations │ ├── pyproject.toml # Python dependencies (managed by uv) @@ -30,8 +43,11 @@ See the [DEMO.md](DEMO.md) for more information about how to effectively use thi │ ├── src/ # React components and pages │ │ ├── api/ # API client and types │ │ ├── components/ # Reusable UI components +│ │ │ └── CurrencySelector.tsx # Currency picker │ │ ├── context/ # React Context providers +│ │ │ └── CurrencyContext.tsx # Currency state management │ │ ├── hooks/ # Custom React hooks +│ │ │ └── useCurrency.ts # Currency hook │ │ ├── mockDB/ # Mock data for development │ │ ├── pages/ # Page-level components │ │ └── theme/ # Chakra UI theme configuration diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b7683b3..73c54de 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -162,16 +162,21 @@ def get_mock_product(overrides: dict = None) -> Product: ``` backend/ ├── app/ -│ ├── models/ # SQLModel database models -│ ├── schemas/ # Pydantic request/response models -│ ├── routers/ # FastAPI route handlers -│ ├── services/ # Business logic layer -│ ├── repositories/ # Database access layer -│ ├── dependencies.py # FastAPI dependencies -│ └── database.py # Database connection setup +│ ├── currency.py # FX rates service, models, schemas +│ ├── models.py # SQLModel database models (Product, Category, DeliveryOption, FxRates) +│ ├── schemas.py # Pydantic request/response models +│ ├── crud.py # Database operations +│ ├── db.py # Database connection setup +│ ├── main.py # FastAPI routes and app entry point +│ └── seed.py # Database seeding script ├── tests/ -│ ├── test_products.py # Product API tests -│ ├── test_orders.py # Order API tests +│ ├── api/ +│ │ ├── test_products.py # Product API tests +│ │ ├── test_currency.py # Currency/FX rates tests +│ │ ├── test_categories.py # Category API tests +│ │ ├── test_delivery_options.py # Delivery options tests +│ │ └── test_images.py # Image handling tests +│ ├── factories.py # Test data factories │ └── conftest.py # Pytest fixtures ├── alembic/ │ └── versions/ # Database migrations @@ -210,6 +215,16 @@ class Order(SQLModel, table=True): - `PUT /products/{id}` - Update product (full) - `PATCH /products/{id}` - Partial update - `DELETE /products/{id}` - Delete product +- `GET /fx/rates` - Get currency exchange rates + +### Currency/FX Rates +- **Endpoint:** `GET /fx/rates` +- **Base Currency:** USD (all prices stored in USD) +- **Supported Currencies:** USD, GBP, EUR, AUD, MXN, JPY +- **Caching:** 6-hour TTL with 3-tier caching (memory → DB → fallback) +- **Provider:** frankfurter.app (free, no auth required) +- **Response:** Includes rates, fetched_at, ttl_seconds, stale flag +- **Graceful Degradation:** Serves stale rates when provider unavailable ### Response Format ```python diff --git a/backend/alembic/versions/50f63877083c_add_fx_rates_table.py b/backend/alembic/versions/50f63877083c_add_fx_rates_table.py new file mode 100644 index 0000000..c5180f5 --- /dev/null +++ b/backend/alembic/versions/50f63877083c_add_fx_rates_table.py @@ -0,0 +1,35 @@ +"""add_fx_rates_table + +Revision ID: 50f63877083c +Revises: 94404b2e4890 +Create Date: 2025-10-03 16:21:30.722478 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '50f63877083c' +down_revision: Union[str, Sequence[str], None] = '94404b2e4890' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + 'fx_rates', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('base', sa.String(), nullable=False), + sa.Column('rates', sa.JSON(), nullable=False), + sa.Column('fetched_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('fx_rates') diff --git a/backend/app/currency.py b/backend/app/currency.py new file mode 100644 index 0000000..6c1623d --- /dev/null +++ b/backend/app/currency.py @@ -0,0 +1,126 @@ +from sqlmodel import SQLModel, Field, Session, select +from sqlalchemy import Column, JSON +from typing import Optional, Literal +from datetime import datetime, UTC +from pydantic import BaseModel +import httpx + + +CurrencyCode = Literal["USD", "GBP", "EUR", "AUD", "MXN", "JPY"] + +TTL_SECONDS = 21600 + + +class FxRates(SQLModel, table=True): + __tablename__ = "fx_rates" + + id: Optional[int] = Field(default=None, primary_key=True) + base: str = Field(default="USD") + rates: dict = Field(sa_column=Column(JSON)) + fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + +class RatesResponse(BaseModel): + base: str + rates: dict[str, float] + fetched_at: datetime + ttl_seconds: int + stale: bool + + +class FxService: + def __init__(self, session: Session): + self.session = session + self._cache: Optional[dict] = None + self._cache_fetched_at: Optional[datetime] = None + + def get_rates(self) -> RatesResponse: + now = datetime.now(UTC) + + if self._is_cache_valid(now) and self._cache is not None and self._cache_fetched_at is not None: + return self._build_response( + self._cache, + self._cache_fetched_at, + stale=False + ) + + try: + fresh_rates = self.fetch_from_provider() + self._cache = fresh_rates + self._cache_fetched_at = now + + self._persist_to_db(fresh_rates, now) + + return self._build_response(fresh_rates, now, stale=False) + except Exception: + db_rates = self._get_from_db() + if db_rates: + self._cache = db_rates["rates"] + self._cache_fetched_at = db_rates["fetched_at"] + return self._build_response( + db_rates["rates"], + db_rates["fetched_at"], + stale=True + ) + + return self._build_response( + {}, + now, + stale=True + ) + + def _is_cache_valid(self, now: datetime) -> bool: + if self._cache is None or self._cache_fetched_at is None: + return False + + age = (now - self._cache_fetched_at).total_seconds() + return age < TTL_SECONDS + + def fetch_from_provider(self) -> dict: + url = "https://api.frankfurter.app/latest" + params = { + "from": "USD", + "to": "GBP,EUR,AUD,MXN,JPY" + } + + response = httpx.get(url, params=params, timeout=10.0) + response.raise_for_status() + + data = response.json() + return data["rates"] + + def _persist_to_db(self, rates: dict, fetched_at: datetime) -> None: + fx_rates = FxRates( + base="USD", + rates=rates, + fetched_at=fetched_at + ) + self.session.add(fx_rates) + self.session.commit() + + def _get_from_db(self) -> Optional[dict]: + stmt = select(FxRates).order_by(FxRates.fetched_at.desc()).limit(1) # type: ignore[attr-defined] + result = self.session.exec(stmt).first() + + if result: + return { + "rates": result.rates, + "fetched_at": result.fetched_at + } + return None + + def _build_response( + self, + rates: dict, + fetched_at: datetime, + stale: bool + ) -> RatesResponse: + all_rates = {"USD": 1.0, **rates} + + return RatesResponse( + base="USD", + rates=all_rates, + fetched_at=fetched_at, + ttl_seconds=TTL_SECONDS, + stale=stale + ) diff --git a/backend/app/db.py b/backend/app/db.py index 4108d49..3248cdd 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -6,7 +6,7 @@ engine = create_engine( DATABASE_URL, connect_args={ - "check_same_thread": False, # SQLite only + "check_same_thread": False, } ) @@ -15,11 +15,13 @@ def get_session(): yield session def create_db_and_tables(): + from .models import Product, Category, DeliveryOption, ProductDeliveryLink # noqa: F401 + from .currency import FxRates # noqa: F401 + SQLModel.metadata.create_all(engine) - # Enable WAL mode for better performance with BLOBs with engine.connect() as conn: conn.execute(text("PRAGMA journal_mode=WAL;")) - conn.execute(text("PRAGMA cache_size=10000;")) # 10MB cache - conn.execute(text("PRAGMA synchronous=NORMAL;")) # better write throughput + conn.execute(text("PRAGMA cache_size=10000;")) + conn.execute(text("PRAGMA synchronous=NORMAL;")) conn.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 1c2983c..3800a50 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ ) from . import crud from .models import Product, DeliveryOption, Category, ProductDeliveryLink +from .currency import FxService, RatesResponse def calculate_delivery_summary(delivery_options: List[DeliveryOption]) -> Optional[DeliverySummary]: """Calculate delivery summary from a list of delivery options""" @@ -427,6 +428,11 @@ def get_product_image( } ) +@app.get("/fx/rates", response_model=RatesResponse) +def get_fx_rates(session: Session = Depends(get_session)): + service = FxService(session) + return service.get_rates() + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/backend/store.db b/backend/store.db index 4ee82ef276a82cd82cf5daf178c7b3e491b76e69..f0084220fae704c26231ebdbc440d968bb086f67 100644 GIT binary patch delta 35075 zcmeI5UyL1BeaH8Yz5mxYb^;DboZL7u*s(pFzjJ05LfANl#5fygZBx<$#&On-i{s?P z4!8w+a?%PUM5S2iLy&mjp;8ebp$G&L;sFG-{FVC9hemD1Q(tIRrQ(l-BBXxL%-+4{ z-o1P8+?hG|X_3xGYp?n2nVECue1Ctw_r>qOG5+G0e>48_?YsAkjZOY?to^^)8%Ov( zd&Z*A-?BLO^4R?JiLu55yIx;Byzs609dobF{Ce(z=|9eVb^63>_9s)qlKJVq`}R$| zM6aEH^77in>*t<7e{Jn*>%WsfKDK&%`ONX=ndJvh9&hG<{L1ylKKbPNt82{)Xj6cKi}uf7?7bH@p7t&Ru!onbz-gkh4P_zreA_ zR!*N;U4CTcO!K_yh$#9`|ltBTb0fBx#$1p&6&@=IWvwi zfia1(17jz~6vi}$`}3Dl}j$%X@@5CS&6oX-K z41pmrVhn|mV5AsJ81MSdn=|j;xp!_6&**sLjj_fX-`w5&M&riI>b70u69*)@81qCa zlJE<2n||cOjmZPE{*Ua$k4!gS$JfbUHr{OfzVUyJ|8D$O<5!K>=U1b; zH`_zYXHP%$sr}1mAKJgvq>-dT9%=4Bes&e#k|>dkF?|322Tx_+E5T#;_a{z0;eK4G zh`Vne{m_d0Hi-mL>@$ood6bf)gf|&kV(Aj)5n~+xR^Q!5n&3>z6UL=(wwXTC)IU!m zBN5X?#`><_Odo0Lxwc^PH0CVPcMfLyNaOBrx%){(ln9Z;Qtum%=_Ac>&k&eO36fHM zM|q}?G=ra8Fd5}47W(#jm_E|77`v0+3#1vz)&5hurk>MnvLw=oBoud4 z&kkbxNK^hqoij-qV@sGBhUp{CaHn`MlZennB}z}1W%@`nSUG^HuslhM)6i5MOdn}_ ztCft&Q<+Gich=4Hk*2MXawZcgm3l`=rjIltj^db0BJ|{_m_TB)Rg&t7DKirBiAx*f zK+Pm0dc0$rrjp==2>*q>g0DZfC-L=X_C9?5shz>spIpS3e|`vG{%OBc{`<$iJJ$HS z#%;U)ahF*9!Q#rozb`yJ|GRm*E!zFpaVt=;E!z9VV75j3z+lt1X!li~uef~m<0}zg z75FFgP0N$;ZPC6h+P6i!e~k2xbpG*bTeOGRhPFj}&{WN~XdfU7wnh8Ebllf`0bwnOQJs(^yA(N+) zBq3ZMYa>Hu+H+={66AjLgKcKWOnc5ur9#qDFZX8FooRPW7A1-)7VGy8X4a8u&*#Zw zk)%>TFdWlg2E%09^Lb*Lk|_P2@=Sl}4a&5)nABa{`|Dx)%e_~oy>&kY68!qz^)vk~ zrj==5pC?6Dgz0yMWv*{)8PY_G(KHo$X>`m8RZR(|nF2Y2OnZK5kvbGI(Mi`d17+Gd zSweOn@pD2Ft-6^0TdKxPd(I?82`Y5lhv~QD%Ge@dOQ3Ne>WZE;PP)HCFeRvSFrc8UzB!m*G?;D2ci@YH-?dcMiQA{PrtL#u&rY|fD zWZE;P6hbhe57xo-#q1WD_Ke9uq^W*K-Aq3$>t)(ACRJQ=rQaTs*$xG#ONOD2R0!;S z`oO4|K+*)oREfTKM49%i%v;8aK$P9nG0ke3_JO74B6TGN^^5k49g_$t+VQYblA;h} zt&I#V+LMR_nF#$=szjQtSH*onXbbk)*mdvEFs$0nMB1{o@XmE3ySuf$??Lb zi9S;!vyW(BXW~_c+UC=Nm|dcMoyih~K;Gℑ}nlJ+_3Uu}M<>_{f;jl^Js(^{Eyz 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 diff --git a/backend/tests/api/test_currency.py b/backend/tests/api/test_currency.py new file mode 100644 index 0000000..826b6f1 --- /dev/null +++ b/backend/tests/api/test_currency.py @@ -0,0 +1,187 @@ +import pytest +from datetime import datetime, UTC, timedelta +from unittest.mock import Mock, patch +from fastapi.testclient import TestClient +from sqlmodel import Session +from typing import Optional + + +@pytest.fixture(autouse=True) +def clean_fx_data(session: Session): + from app.currency import FxRates + from sqlmodel import select + + stmt = select(FxRates) + rates = session.exec(stmt).all() + for rate in rates: + session.delete(rate) + session.commit() + yield + + +def get_mock_fx_rates(overrides: Optional[dict] = None) -> dict: + base = { + "GBP": 0.79, + "EUR": 0.92, + "AUD": 1.52, + "MXN": 17.25, + "JPY": 149.50 + } + return base | (overrides or {}) + + +def get_mock_provider_response(overrides: Optional[dict] = None) -> dict: + base = { + "base": "USD", + "date": "2025-10-03", + "rates": get_mock_fx_rates() + } + return base | (overrides or {}) + + +def test_rates_empty_cache_fetches_from_provider(client: TestClient): + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = get_mock_provider_response() + mock_get.return_value = mock_response + + response = client.get("/fx/rates") + + assert response.status_code == 200 + data = response.json() + assert data["base"] == "USD" + assert data["rates"]["USD"] == 1.0 + assert data["rates"]["GBP"] == 0.79 + assert data["rates"]["EUR"] == 0.92 + assert data["rates"]["AUD"] == 1.52 + assert data["rates"]["MXN"] == 17.25 + assert data["rates"]["JPY"] == 149.50 + assert data["stale"] is False + assert data["ttl_seconds"] == 21600 + assert "fetched_at" in data + + mock_get.assert_called_once() + call_args = mock_get.call_args + assert "https://api.frankfurter.app/latest" in call_args[0][0] + + +def test_rates_within_ttl_uses_cache(client: TestClient): + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = get_mock_provider_response() + mock_get.return_value = mock_response + + response1 = client.get("/fx/rates") + assert response1.status_code == 200 + + response2 = client.get("/fx/rates") + assert response2.status_code == 200 + + data2 = response2.json() + assert data2["rates"]["GBP"] == 0.79 + assert data2["stale"] is False + + assert mock_get.call_count >= 1 + + +def test_rates_expired_refetches(client: TestClient): + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = get_mock_provider_response() + mock_get.return_value = mock_response + + with patch("app.currency.datetime") as mock_datetime: + now = datetime.now(UTC) + mock_datetime.now.return_value = now + mock_datetime.UTC = UTC + + response1 = client.get("/fx/rates") + assert response1.status_code == 200 + + future = now + timedelta(hours=7) + mock_datetime.now.return_value = future + + mock_response.json.return_value = get_mock_provider_response({ + "rates": get_mock_fx_rates({"GBP": 0.81}) + }) + + response2 = client.get("/fx/rates") + assert response2.status_code == 200 + + data2 = response2.json() + assert data2["rates"]["GBP"] == 0.81 + + assert mock_get.call_count == 2 + + +def test_provider_failure_serves_stale_from_db(client: TestClient, session): + from app.currency import FxRates + + old_rates = FxRates( + base="USD", + rates=get_mock_fx_rates(), + fetched_at=datetime.now(UTC) - timedelta(hours=10) + ) + session.add(old_rates) + session.commit() + + with patch("httpx.get") as mock_get: + mock_get.side_effect = Exception("Network error") + + response = client.get("/fx/rates") + + assert response.status_code == 200 + data = response.json() + assert data["base"] == "USD" + assert data["rates"]["GBP"] == 0.79 + assert data["stale"] is True + + +def test_no_persistent_rates_returns_usd_only_stale_true(client: TestClient): + with patch("httpx.get") as mock_get: + mock_get.side_effect = Exception("Network error") + + response = client.get("/fx/rates") + + assert response.status_code == 200 + data = response.json() + assert data["base"] == "USD" + assert data["rates"] == {"USD": 1.0} + assert data["stale"] is True + assert "fetched_at" in data + + +def test_response_includes_all_currencies(client: TestClient): + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = get_mock_provider_response() + mock_get.return_value = mock_response + + response = client.get("/fx/rates") + + assert response.status_code == 200 + data = response.json() + + expected_currencies = {"USD", "GBP", "EUR", "AUD", "MXN", "JPY"} + assert set(data["rates"].keys()) == expected_currencies + + assert data["rates"]["USD"] == 1.0 + + +def test_provider_returns_non_200_status(client: TestClient): + with patch("httpx.get") as mock_get: + mock_response = Mock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = Exception("Server error") + mock_get.return_value = mock_response + + response = client.get("/fx/rates") + + assert response.status_code == 200 + data = response.json() + assert data["rates"] == {"USD": 1.0} + assert data["stale"] is True diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5214673..252363e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -14,6 +14,7 @@ from app.db import get_session # noqa: E402 from app.models import SQLModel # noqa: E402 from app.seed import seed_database # noqa: E402 +from app.currency import FxRates # noqa: E402, F401 @pytest.fixture(scope="session") def test_db(): diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index e5e0c3b..8fef3f3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -5,7 +5,8 @@ **Purpose:** React TypeScript frontend for e-commerce demo with strict TDD practices **Stack:** React 18, TypeScript, Vite, Chakra UI, React Testing Library, Playwright **Design System:** Modular theme with semantic tokens (sand/ink/charcoal colors, Inter font) -**Architecture:** Component-based with Context API for state management +**Architecture:** Component-based with Context API for state management +**Multi-Currency:** Supports USD, GBP, EUR, AUD, MXN, JPY with live FX rates from backend ## Essential Commands @@ -271,6 +272,44 @@ export const CartProvider: FC<{ children: ReactNode }> = ({ children }) => { } ``` +### Currency Management + +**Context:** `CurrencyContext` manages currency selection, FX rates, and price formatting. + +**Hook:** `useCurrency()` provides: +- `currency` - Currently selected currency (USD/GBP/EUR/AUD/MXN/JPY) +- `setCurrency(code)` - Change currency preference +- `rates` - Exchange rates from backend +- `convert(usdAmount)` - Convert USD to selected currency +- `format(usdAmount)` - Convert and format with proper symbol/decimals +- `fetchedAt`, `stale` - Rate freshness indicators + +**Storage:** +- Selected currency persisted in localStorage +- Auto-detects from navigator.language on first visit +- FX rates cached in localStorage with 6hr TTL + +**Price Display:** +- All prices stored/calculated in USD +- Conversion happens only for display purposes +- Use `format(price)` instead of `${price}` in all components +- Intl.NumberFormat handles currency-specific formatting (JPY=0 decimals) + +**Component Integration:** +```typescript +import { useCurrency } from '../hooks/useCurrency' + +export const ProductCard = ({ product }) => { + const { format } = useCurrency() + + return ( + + {format(product.price)} + + ) +} +``` + ## UI Component Standards ### Chakra UI Integration @@ -507,12 +546,25 @@ frontend/src/ │ ├── ProductCard/ │ │ ├── ProductCard.tsx │ │ └── ProductCard.test.tsx +│ ├── CurrencySelector.tsx # Currency picker dropdown +│ ├── CurrencySelector.test.tsx +│ ├── CartItem/ # Cart item display +│ ├── Delivery/ # Delivery option components +│ └── Header.tsx # App header with currency selector ├── pages/ # Page-level components +│ ├── Cart.tsx # Shopping cart (uses currency formatting) +│ ├── Product.tsx # Product detail page +│ └── Home.tsx # Homepage ├── hooks/ # Custom React hooks +│ └── useCurrency.ts # Currency context hook ├── context/ # React Context providers +│ ├── CurrencyContext.tsx # Currency state, FX rates, formatting +│ ├── GlobalState.tsx # Cart and product state +│ └── index.ts # Context exports ├── api/ # API client and types ├── types/ # TypeScript type definitions ├── utils/ # Helper functions +│ └── currency.test.ts # Currency utility tests └── __tests__/ # Test utilities and global mocks ``` diff --git a/frontend/e2e/tests/currency.spec.ts b/frontend/e2e/tests/currency.spec.ts new file mode 100644 index 0000000..cf15414 --- /dev/null +++ b/frontend/e2e/tests/currency.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from '@playwright/test'; +import { waitForProductsLoaded } from './utils/waits'; + +test.describe('Multi-Currency Functionality', () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + try { + localStorage.removeItem('cart'); + localStorage.removeItem('cartItems'); + localStorage.removeItem('selectedCurrency'); + } catch (e) { + // Ignore storage errors + } + }); + + await page.route('**/fx/rates', async route => { + await route.fulfill({ + status: 200, + body: JSON.stringify({ + base: 'USD', + rates: { USD: 1, GBP: 0.79, EUR: 0.92, AUD: 1.52, MXN: 18.5, JPY: 157 }, + fetched_at: new Date().toISOString(), + ttl_seconds: 21600, + stale: false + }) + }); + }); + + await page.goto('/'); + await waitForProductsLoaded(page); + }); + + test('test_currency_selector_displays_in_header', async ({ page }) => { + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + + await expect(currencySelector).toBeVisible(); + + const currencyText = await currencySelector.textContent(); + expect(currencyText).toContain('$'); + }); + + test('test_switching_currency_updates_product_prices', async ({ page }) => { + const firstProduct = page.locator('[data-testid^="product-"]').first(); + const priceLocator = firstProduct.locator('[data-testid="product-price"]'); + + const initialPrice = await priceLocator.textContent(); + expect(initialPrice).toContain('$'); + + const initialAmount = parseFloat(initialPrice?.replace(/[^0-9.]/g, '') || '0'); + + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + await currencySelector.click(); + + const gbpOption = page.locator('[data-testid="currency-option-GBP"]'); + await gbpOption.click(); + + await page.waitForTimeout(500); + + const updatedPrice = await priceLocator.textContent(); + expect(updatedPrice).toContain('£'); + + const updatedAmount = parseFloat(updatedPrice?.replace(/[^0-9.]/g, '') || '0'); + + expect(updatedAmount).not.toBe(initialAmount); + expect(updatedAmount).toBeGreaterThan(0); + + const expectedAmount = initialAmount * 0.79; + expect(Math.abs(updatedAmount - expectedAmount)).toBeLessThan(1); + }); + + test('test_switching_currency_updates_cart_total', async ({ page }) => { + const addToCartButton = page.locator('[data-testid="add-to-cart"]').first(); + await addToCartButton.click(); + + await page.waitForTimeout(500); + + const cartLink = page.locator('[data-testid="cart-link"]'); + await cartLink.click(); + await page.waitForURL('**/cart'); + + const cartTotal = page.locator('[data-testid="cart-total"]'); + const initialTotal = await cartTotal.textContent(); + expect(initialTotal).toContain('$'); + + const initialAmount = parseFloat(initialTotal?.replace(/[^0-9.]/g, '') || '0'); + + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + await currencySelector.click(); + + const eurOption = page.locator('[data-testid="currency-option-EUR"]'); + await eurOption.click(); + + await page.waitForTimeout(500); + + const updatedTotal = await cartTotal.textContent(); + expect(updatedTotal).toContain('€'); + + const updatedAmount = parseFloat(updatedTotal?.replace(/[^0-9.]/g, '') || '0'); + + expect(updatedAmount).not.toBe(initialAmount); + expect(updatedAmount).toBeGreaterThan(0); + + const expectedAmount = initialAmount * 0.92; + expect(Math.abs(updatedAmount - expectedAmount)).toBeLessThan(1); + }); + + test('test_currency_selection_persists_across_reload', async ({ page }) => { + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + await currencySelector.click(); + + const jpyOption = page.locator('[data-testid="currency-option-JPY"]'); + await jpyOption.click(); + + await page.waitForTimeout(500); + + const firstProduct = page.locator('[data-testid^="product-"]').first(); + const priceLocator = firstProduct.locator('[data-testid="product-price"]'); + const priceBeforeReload = await priceLocator.textContent(); + expect(priceBeforeReload).toContain('¥'); + + await page.reload(); + await waitForProductsLoaded(page); + + const currencySelectorAfterReload = page.locator('[data-testid="currency-selector-button"]'); + const currencyText = await currencySelectorAfterReload.textContent(); + expect(currencyText).toContain('¥'); + + const priceAfterReload = await priceLocator.textContent(); + expect(priceAfterReload).toContain('¥'); + }); + + test('test_all_supported_currencies_available', async ({ page }) => { + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + await currencySelector.click(); + + const currencies = ['USD', 'GBP', 'EUR', 'AUD', 'MXN', 'JPY']; + + for (const currency of currencies) { + const currencyOption = page.locator(`[data-testid="currency-option-${currency}"]`); + await expect(currencyOption).toBeVisible(); + } + + await page.keyboard.press('Escape'); + }); + + test('test_currency_works_when_fx_api_fails', async ({ page }) => { + await page.unroute('**/fx/rates'); + + await page.route('**/fx/rates', async route => { + await route.fulfill({ + status: 500, + body: JSON.stringify({ error: 'Internal Server Error' }) + }); + }); + + await page.reload(); + await waitForProductsLoaded(page); + + const firstProduct = page.locator('[data-testid^="product-"]').first(); + await expect(firstProduct).toBeVisible(); + + const priceLocator = firstProduct.locator('[data-testid="product-price"]'); + const price = await priceLocator.textContent(); + expect(price).toBeTruthy(); + expect(price?.length).toBeGreaterThan(0); + + const currencySelector = page.locator('[data-testid="currency-selector-button"]'); + await expect(currencySelector).toBeVisible(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 9ae9f08..6621da0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,9 @@ "build": "tsc && vite build", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "cross-env HEADED=0 playwright test", "test:e2e:headed": "cross-env HEADED=1 playwright test", "test:e2e:ui": "playwright test --ui", @@ -33,6 +36,8 @@ }, "devDependencies": { "@playwright/test": "^1.55.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/loadable__component": "^5.13.4", "@types/node": "^24.5.1", "@types/react": "^18.0.28", @@ -40,11 +45,14 @@ "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", "@vitejs/plugin-react": "^4.0.0-beta.0", + "@vitest/ui": "^3.2.4", "concurrently": "^9.2.1", "cross-env": "^10.0.0", "csstype": "^3.1.2", "eslint": "^9.35.0", + "jsdom": "^27.0.0", "typescript": "^5.0.2", - "vite": "^7.1.6" + "vite": "^7.1.6", + "vitest": "^3.2.4" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff5c760..ed28e36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; import Container from "./components/Container"; import ProgressLine from "./components/Loading/ProgressLine"; import { Provider } from "./context/GlobalState"; +import { CurrencyProvider } from "./context/CurrencyContext"; import Saved from "./pages/Saved"; const Home = loadable(() => import("./pages/Home"), { @@ -26,23 +27,25 @@ const Register = loadable(() => import("./pages/Register"), { const App = () => { return ( - - - + + + + + + } /> + } /> + } /> + } /> + } /> + + - } /> - } /> - } /> - } /> - } /> + } /> + } /> - - - } /> - } /> - - - + + + ); }; diff --git a/frontend/src/components/CartItem/CartItem.tsx b/frontend/src/components/CartItem/CartItem.tsx index bd93349..fff2152 100644 --- a/frontend/src/components/CartItem/CartItem.tsx +++ b/frontend/src/components/CartItem/CartItem.tsx @@ -17,18 +17,17 @@ import { useState } from "react"; import { Link as RouterLink } from "react-router-dom"; import { ProductInCart, getImageUrl } from "../../context/GlobalState"; import { useGlobalContext } from "../../context/useGlobalContext"; +import { useCurrency } from "../../hooks/useCurrency"; import MotionBox from "../MotionBox"; type Props = { product: ProductInCart; }; -const formatUSD = (n: number) => - new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n); - const CartItem = ({ product }: Props) => { const [imgLoaded, setImgLoaded] = useState(false); const { setQuantity, deleteFromCart, toggleSaved } = useGlobalContext(); + const { format } = useCurrency(); const subTotal = +product.price * +product.quantity; const handleQuantityChange = (_: string, valueAsNumber: number) => { @@ -124,7 +123,7 @@ const CartItem = ({ product }: Props) => { - {formatUSD(subTotal)} + {format(subTotal)} { - {formatUSD(+product.price)} each + {format(+product.price)} each diff --git a/frontend/src/components/CartItem/CartItemMobile.tsx b/frontend/src/components/CartItem/CartItemMobile.tsx index 6ff3ef8..d9771c1 100644 --- a/frontend/src/components/CartItem/CartItemMobile.tsx +++ b/frontend/src/components/CartItem/CartItemMobile.tsx @@ -15,20 +15,19 @@ import { BiTrash } from "react-icons/bi"; import { Link as RouterLink } from "react-router-dom"; import { ProductInCart, getImageUrl } from "../../context/GlobalState"; import { useGlobalContext } from "../../context/useGlobalContext"; +import { useCurrency } from "../../hooks/useCurrency"; import MotionBox from "../MotionBox"; type Props = { product: ProductInCart; }; -const formatUSD = (n: number) => - new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n); - const CartItemMobile = ({ product }: Props) => { const [imgLoaded, setImgLoaded] = useState(false); const subTotal = +product.price * +product.quantity; const { deleteFromCart, incrementQty, decrementQty, toggleSaved } = useGlobalContext(); + const { format } = useCurrency(); return ( { - {formatUSD(subTotal)} + {format(subTotal)} - {formatUSD(+product.price)} each + {format(+product.price)} each diff --git a/frontend/src/components/CurrencySelector.test.tsx b/frontend/src/components/CurrencySelector.test.tsx new file mode 100644 index 0000000..832cb85 --- /dev/null +++ b/frontend/src/components/CurrencySelector.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { CurrencySelector } from './CurrencySelector' +import { Currency, CurrencyContext } from '../context/CurrencyContext' +import { ReactNode } from 'react' + +const getMockCurrencyContext = (overrides?: Partial<{ + currency: Currency + setCurrency: (currency: Currency) => void +}>) => ({ + currency: 'USD' as Currency, + setCurrency: vi.fn(), + rates: { USD: 1, GBP: 0.79, EUR: 0.92, AUD: 1.52, MXN: 20.5, JPY: 149.5 }, + fetchedAt: new Date(), + stale: false, + convert: vi.fn(), + format: vi.fn(), + ...overrides, +}) + +const renderWithCurrencyContext = ( + component: ReactNode, + contextValue: ReturnType +) => { + return render( + + {component} + + ) +} + +describe('CurrencySelector', () => { + it('test_displays_current_currency', () => { + const contextValue = getMockCurrencyContext({ currency: 'USD' }) + + renderWithCurrencyContext(, contextValue) + + const button = screen.getByTestId('currency-selector-button') + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent('$') + }) + + it('test_shows_all_six_currencies_in_menu', () => { + const contextValue = getMockCurrencyContext() + + renderWithCurrencyContext(, contextValue) + + const button = screen.getByTestId('currency-selector-button') + fireEvent.click(button) + + expect(screen.getByTestId('currency-option-USD')).toBeInTheDocument() + expect(screen.getByTestId('currency-option-GBP')).toBeInTheDocument() + expect(screen.getByTestId('currency-option-EUR')).toBeInTheDocument() + expect(screen.getByTestId('currency-option-AUD')).toBeInTheDocument() + expect(screen.getByTestId('currency-option-MXN')).toBeInTheDocument() + expect(screen.getByTestId('currency-option-JPY')).toBeInTheDocument() + + expect(screen.getByText('US Dollar')).toBeInTheDocument() + expect(screen.getByText('British Pound')).toBeInTheDocument() + expect(screen.getByText('Euro')).toBeInTheDocument() + expect(screen.getByText('Australian Dollar')).toBeInTheDocument() + expect(screen.getByText('Mexican Peso')).toBeInTheDocument() + expect(screen.getByText('Japanese Yen')).toBeInTheDocument() + }) + + it('test_clicking_currency_calls_setCurrency', () => { + const mockSetCurrency = vi.fn() + const contextValue = getMockCurrencyContext({ + currency: 'USD', + setCurrency: mockSetCurrency + }) + + renderWithCurrencyContext(, contextValue) + + const button = screen.getByTestId('currency-selector-button') + fireEvent.click(button) + + const eurOption = screen.getByTestId('currency-option-EUR') + fireEvent.click(eurOption) + + expect(mockSetCurrency).toHaveBeenCalledWith('EUR') + }) + + it('test_selected_currency_is_highlighted', () => { + const contextValue = getMockCurrencyContext({ currency: 'GBP' }) + + renderWithCurrencyContext(, contextValue) + + const button = screen.getByTestId('currency-selector-button') + expect(button).toHaveTextContent('£') + + fireEvent.click(button) + + const gbpOption = screen.getByTestId('currency-option-GBP') + expect(gbpOption.querySelector('svg')).toBeInTheDocument() + + const usdOption = screen.getByTestId('currency-option-USD') + expect(usdOption.querySelector('svg')).toBeNull() + }) + + it('test_displays_correct_symbols_for_each_currency', () => { + const contextValue = getMockCurrencyContext() + + renderWithCurrencyContext(, contextValue) + + const button = screen.getByTestId('currency-selector-button') + fireEvent.click(button) + + const symbols = ['$', '£', '€', 'A$', 'Mex$', '¥'] + symbols.forEach(symbol => { + expect(screen.getAllByText(symbol).length).toBeGreaterThan(0) + }) + }) +}) diff --git a/frontend/src/components/CurrencySelector.tsx b/frontend/src/components/CurrencySelector.tsx new file mode 100644 index 0000000..f68e9c5 --- /dev/null +++ b/frontend/src/components/CurrencySelector.tsx @@ -0,0 +1,107 @@ +import { CheckIcon } from '@chakra-ui/icons' +import { + Box, + Button, + HStack, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, +} from '@chakra-ui/react' +import { useContext } from 'react' +import { Currency, CurrencyContext, SUPPORTED_CURRENCIES } from '../context/CurrencyContext' + +const CURRENCY_SYMBOLS: Record = { + USD: '$', + GBP: '£', + EUR: '€', + AUD: 'A$', + MXN: 'Mex$', + JPY: '¥', +} + +const CURRENCY_NAMES: Record = { + USD: 'US Dollar', + GBP: 'British Pound', + EUR: 'Euro', + AUD: 'Australian Dollar', + MXN: 'Mexican Peso', + JPY: 'Japanese Yen', +} + +export const useCurrency = () => { + const context = useContext(CurrencyContext) + if (!context) { + throw new Error('useCurrency must be used within CurrencyProvider') + } + return context +} + +export const CurrencySelector = () => { + const { currency, setCurrency } = useCurrency() + + return ( + + + + {CURRENCY_SYMBOLS[currency]} + + + + {SUPPORTED_CURRENCIES.map((curr) => ( + setCurrency(curr)} + bg="bg.surface" + _hover={{ bg: 'bg.subtle' }} + _active={{ bg: 'bg.card' }} + data-testid={`currency-option-${curr}`} + > + + + + {CURRENCY_SYMBOLS[curr]} + + + {CURRENCY_NAMES[curr]} + + + {currency === curr && ( + + + + )} + + + ))} + + + ) +} diff --git a/frontend/src/components/Delivery/DeliveryOptionsSelector.tsx b/frontend/src/components/Delivery/DeliveryOptionsSelector.tsx index d3b213e..cedf4bf 100644 --- a/frontend/src/components/Delivery/DeliveryOptionsSelector.tsx +++ b/frontend/src/components/Delivery/DeliveryOptionsSelector.tsx @@ -7,6 +7,7 @@ import { RadioGroup, } from "@chakra-ui/react"; import { DeliveryOption } from "../../context/GlobalState"; +import { useCurrency } from "../../hooks/useCurrency"; import { DeliverySpeedIcon } from "./DeliverySpeedIcon"; interface DeliveryOptionsSelectorProps { @@ -16,10 +17,6 @@ interface DeliveryOptionsSelectorProps { onChange: (id: string) => void; } -const formatPrice = (price: number): string => { - return price === 0 ? "Free" : `$${price.toFixed(2)}`; -}; - const formatEta = (min: number, max: number): string => { if (min === max) { return min === 0 ? "Same day" : min === 1 ? "1 business day" : `${min} business days`; @@ -33,6 +30,12 @@ export const DeliveryOptionsSelector = ({ value, onChange, }: DeliveryOptionsSelectorProps) => { + const { format } = useCurrency(); + + const formatPrice = (price: number): string => { + return price === 0 ? "Free" : format(price); + }; + // Filter and sort options const activeOptions = options.filter(o => o.is_active); const speedOrder = { standard: 0, express: 1, next_day: 2, same_day: 3 }; diff --git a/frontend/src/components/Delivery/DeliveryOptionsSummary.tsx b/frontend/src/components/Delivery/DeliveryOptionsSummary.tsx index bfc955d..d87d070 100644 --- a/frontend/src/components/Delivery/DeliveryOptionsSummary.tsx +++ b/frontend/src/components/Delivery/DeliveryOptionsSummary.tsx @@ -1,14 +1,11 @@ import { Box, Text } from "@chakra-ui/react"; import { DeliverySummary } from "../../context/GlobalState"; +import { useCurrency } from "../../hooks/useCurrency"; interface DeliveryOptionsSummaryProps { summary?: DeliverySummary | null; } -const formatPrice = (price: number): string => { - return price === 0 ? "Free" : `$${price.toFixed(2)}`; -}; - const formatEta = (min: number, max: number): string => { if (min === max) { return min === 0 ? "Same day" : min === 1 ? "1 day" : `${min} days`; @@ -17,6 +14,12 @@ const formatEta = (min: number, max: number): string => { }; export const DeliveryOptionsSummary = ({ summary }: DeliveryOptionsSummaryProps) => { + const { format } = useCurrency(); + + const formatPrice = (price: number): string => { + return price === 0 ? "Free" : format(price); + }; + if (!summary) return null; if (summary.has_free) { diff --git a/frontend/src/components/FeaturedBanner.tsx b/frontend/src/components/FeaturedBanner.tsx index 73eeda3..acee260 100644 --- a/frontend/src/components/FeaturedBanner.tsx +++ b/frontend/src/components/FeaturedBanner.tsx @@ -1,12 +1,14 @@ import { Box, Button, Flex, Image, Text, AspectRatio } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import { ProductType, getImageUrl } from "../context/GlobalState"; +import { useCurrency } from "../hooks/useCurrency"; type Props = { product: ProductType; }; const FeaturedBanner = ({ product }: Props) => { + const { format } = useCurrency(); return ( { - ${product.price} + {format(+product.price)}