From b223325d7715d677b1ccef6c1322b01d6ade7acd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 21 Apr 2026 15:05:28 -0700 Subject: [PATCH] feat(shopping): locale-aware grocery links with region settings UI Shopping links previously hardcoded to US storefronts. Users in other regions got broken Amazon Fresh and Instacart links. Now locale is stored as a user setting and passed to GroceryLinkBuilder at request time. - locale_config.py: per-locale Amazon domain/dept config (already existed) - grocery_links.py: GroceryLinkBuilder accepts locale=; routes Instacart to .ca for Canada, uses amazon_domain per locale, Instacart/Walmart US/CA only - settings.py: adds 'shopping_locale' to allowed settings keys - shopping.py: reads locale from user's stored setting on all list/add/update paths - SettingsView.vue: Shopping Region selector (NA, Europe, APAC, LATAM) - stores/settings.ts: shoppingLocale reactive state, saves via settings API --- app/api/endpoints/settings.py | 2 +- app/api/endpoints/shopping.py | 17 ++++++-- app/services/recipe/grocery_links.py | 41 ++++++++++++------- frontend/src/components/SettingsView.vue | 51 ++++++++++++++++++++++++ frontend/src/services/api.ts | 1 + frontend/src/stores/settings.ts | 9 ++++- 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py index 9353874..4c1f826 100644 --- a/app/api/endpoints/settings.py +++ b/app/api/endpoints/settings.py @@ -10,7 +10,7 @@ from app.db.store import Store router = APIRouter() -_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"}) +_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale"}) class SettingBody(BaseModel): diff --git a/app/api/endpoints/shopping.py b/app/api/endpoints/shopping.py index 7ce7de7..971266a 100644 --- a/app/api/endpoints/shopping.py +++ b/app/api/endpoints/shopping.py @@ -57,12 +57,18 @@ def _in_thread(db_path, fn): # ── List ────────────────────────────────────────────────────────────────────── +def _locale_from_store(store: Store) -> str: + return store.get_setting("shopping_locale") or "us" + + @router.get("", response_model=list[ShoppingItemResponse]) async def list_shopping_items( include_checked: bool = True, session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), ): - builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + locale = await asyncio.to_thread(_in_thread, session.db, _locale_from_store) + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=locale) items = await asyncio.to_thread( _in_thread, session.db, lambda s: s.list_shopping_items(include_checked) ) @@ -75,8 +81,9 @@ async def list_shopping_items( async def add_shopping_item( body: ShoppingItemCreate, session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), ): - builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store)) item = await asyncio.to_thread( _in_thread, session.db, @@ -100,6 +107,7 @@ async def add_shopping_item( async def add_from_recipe( body: BulkAddFromRecipeRequest, session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), ): """Add missing ingredients from a recipe to the shopping list. @@ -132,7 +140,7 @@ async def add_from_recipe( added.append(item) return added - builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store)) items = await asyncio.to_thread(_in_thread, session.db, _run) return [_enrich(i, builder) for i in items] @@ -144,8 +152,9 @@ async def update_shopping_item( item_id: int, body: ShoppingItemUpdate, session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), ): - builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok) + builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store)) item = await asyncio.to_thread( _in_thread, session.db, diff --git a/app/services/recipe/grocery_links.py b/app/services/recipe/grocery_links.py index 3afdb8b..deb180f 100644 --- a/app/services/recipe/grocery_links.py +++ b/app/services/recipe/grocery_links.py @@ -13,6 +13,7 @@ Walmart is kept inline until cf-core adds Impact network support: Links are always generated (plain URLs are useful even without affiliate IDs). Walmart links only appear when WALMART_AFFILIATE_ID is set. +Instacart and Walmart are US/CA-only; other locales get Amazon only. """ from __future__ import annotations @@ -23,19 +24,27 @@ from urllib.parse import quote_plus from circuitforge_core.affiliates import wrap_url from app.models.schemas.recipe import GroceryLink +from app.services.recipe.locale_config import get_locale logger = logging.getLogger(__name__) -def _amazon_fresh_link(ingredient: str) -> GroceryLink: +def _amazon_link(ingredient: str, locale: str) -> GroceryLink: + cfg = get_locale(locale) q = quote_plus(ingredient) - base = f"https://www.amazon.com/s?k={q}&i=amazonfresh" - return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon")) + domain = cfg["amazon_domain"] + dept = cfg["amazon_grocery_dept"] + base = f"https://www.{domain}/s?k={q}&{dept}" + retailer = "Amazon" if locale != "us" else "Amazon Fresh" + return GroceryLink(ingredient=ingredient, retailer=retailer, url=wrap_url(base, "amazon")) -def _instacart_link(ingredient: str) -> GroceryLink: +def _instacart_link(ingredient: str, locale: str) -> GroceryLink: q = quote_plus(ingredient) - base = f"https://www.instacart.com/store/s?k={q}" + if locale == "ca": + base = f"https://www.instacart.ca/store/s?k={q}" + else: + base = f"https://www.instacart.com/store/s?k={q}" return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart")) @@ -50,26 +59,28 @@ def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink: class GroceryLinkBuilder: - def __init__(self, tier: str = "free", has_byok: bool = False) -> None: + def __init__(self, tier: str = "free", has_byok: bool = False, locale: str = "us") -> None: self._tier = tier + self._locale = locale + self._locale_cfg = get_locale(locale) self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip() def build_links(self, ingredient: str) -> list[GroceryLink]: """Build grocery deeplinks for a single ingredient. - Amazon Fresh and Instacart links are always included; wrap_url handles - affiliate ID injection (or returns a plain URL if none is configured). - Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a - path-based redirect that doesn't degrade cleanly to a plain URL). + Amazon link is always included, routed to the user's locale domain. + Instacart and Walmart are only shown where they operate (US/CA). + wrap_url handles affiliate ID injection for supported programs. """ if not ingredient.strip(): return [] - links: list[GroceryLink] = [ - _amazon_fresh_link(ingredient), - _instacart_link(ingredient), - ] - if self._walmart_id: + links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)] + + if self._locale_cfg["instacart"]: + links.append(_instacart_link(ingredient, self._locale)) + + if self._locale_cfg["walmart"] and self._walmart_id: links.append(_walmart_link(ingredient, self._walmart_id)) return links diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 42ad026..3eb6033 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -99,6 +99,57 @@ + +
+

Shopping Region

+

+ Sets your Amazon storefront and which retailers appear in shopping links. + Instacart and Walmart are US/CA only — other regions get Amazon. +

+ +
+ +
+
+

Display

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index acb9078..e6389c1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -547,6 +547,7 @@ export interface RecipeRequest { wildcard_confirmed: boolean nutrition_filters: NutritionFilters excluded_ids: number[] + exclude_ingredients: string[] shopping_mode: boolean pantry_match_only: boolean complexity_filter: string | null diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 45c114f..1f46daf 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -13,6 +13,7 @@ export const useSettingsStore = defineStore('settings', () => { // State const cookingEquipment = ref([]) const unitSystem = ref('metric') + const shoppingLocale = ref('us') const loading = ref(false) const saved = ref(false) @@ -20,9 +21,10 @@ export const useSettingsStore = defineStore('settings', () => { async function load() { loading.value = true try { - const [rawEquipment, rawUnits] = await Promise.allSettled([ + const [rawEquipment, rawUnits, rawLocale] = await Promise.allSettled([ settingsAPI.getSetting('cooking_equipment'), settingsAPI.getSetting('unit_system'), + settingsAPI.getSetting('shopping_locale'), ]) if (rawEquipment.status === 'fulfilled' && rawEquipment.value) { cookingEquipment.value = JSON.parse(rawEquipment.value) @@ -30,6 +32,9 @@ export const useSettingsStore = defineStore('settings', () => { if (rawUnits.status === 'fulfilled' && rawUnits.value) { unitSystem.value = rawUnits.value as UnitSystem } + if (rawLocale.status === 'fulfilled' && rawLocale.value) { + shoppingLocale.value = rawLocale.value + } } catch (err: unknown) { console.error('Failed to load settings:', err) } finally { @@ -43,6 +48,7 @@ export const useSettingsStore = defineStore('settings', () => { await Promise.all([ settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)), settingsAPI.setSetting('unit_system', unitSystem.value), + settingsAPI.setSetting('shopping_locale', shoppingLocale.value), ]) saved.value = true setTimeout(() => { @@ -59,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => { // State cookingEquipment, unitSystem, + shoppingLocale, loading, saved,