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
This commit is contained in:
pyr0ball 2026-04-21 15:05:28 -07:00
parent f1d35dd1ac
commit b223325d77
6 changed files with 100 additions and 21 deletions

View file

@ -10,7 +10,7 @@ from app.db.store import Store
router = APIRouter() router = APIRouter()
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"}) _ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale"})
class SettingBody(BaseModel): class SettingBody(BaseModel):

View file

@ -57,12 +57,18 @@ def _in_thread(db_path, fn):
# ── List ────────────────────────────────────────────────────────────────────── # ── List ──────────────────────────────────────────────────────────────────────
def _locale_from_store(store: Store) -> str:
return store.get_setting("shopping_locale") or "us"
@router.get("", response_model=list[ShoppingItemResponse]) @router.get("", response_model=list[ShoppingItemResponse])
async def list_shopping_items( async def list_shopping_items(
include_checked: bool = True, include_checked: bool = True,
session: CloudUser = Depends(get_session), 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( items = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked) _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( async def add_shopping_item(
body: ShoppingItemCreate, body: ShoppingItemCreate,
session: CloudUser = Depends(get_session), 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( item = await asyncio.to_thread(
_in_thread, _in_thread,
session.db, session.db,
@ -100,6 +107,7 @@ async def add_shopping_item(
async def add_from_recipe( async def add_from_recipe(
body: BulkAddFromRecipeRequest, body: BulkAddFromRecipeRequest,
session: CloudUser = Depends(get_session), session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
): ):
"""Add missing ingredients from a recipe to the shopping list. """Add missing ingredients from a recipe to the shopping list.
@ -132,7 +140,7 @@ async def add_from_recipe(
added.append(item) added.append(item)
return added 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) items = await asyncio.to_thread(_in_thread, session.db, _run)
return [_enrich(i, builder) for i in items] return [_enrich(i, builder) for i in items]
@ -144,8 +152,9 @@ async def update_shopping_item(
item_id: int, item_id: int,
body: ShoppingItemUpdate, body: ShoppingItemUpdate,
session: CloudUser = Depends(get_session), 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( item = await asyncio.to_thread(
_in_thread, _in_thread,
session.db, session.db,

View file

@ -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). Links are always generated (plain URLs are useful even without affiliate IDs).
Walmart links only appear when WALMART_AFFILIATE_ID is set. 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 from __future__ import annotations
@ -23,19 +24,27 @@ from urllib.parse import quote_plus
from circuitforge_core.affiliates import wrap_url from circuitforge_core.affiliates import wrap_url
from app.models.schemas.recipe import GroceryLink from app.models.schemas.recipe import GroceryLink
from app.services.recipe.locale_config import get_locale
logger = logging.getLogger(__name__) 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) q = quote_plus(ingredient)
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh" domain = cfg["amazon_domain"]
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon")) 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) 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")) 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: 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._tier = tier
self._locale = locale
self._locale_cfg = get_locale(locale)
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip() self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
def build_links(self, ingredient: str) -> list[GroceryLink]: def build_links(self, ingredient: str) -> list[GroceryLink]:
"""Build grocery deeplinks for a single ingredient. """Build grocery deeplinks for a single ingredient.
Amazon Fresh and Instacart links are always included; wrap_url handles Amazon link is always included, routed to the user's locale domain.
affiliate ID injection (or returns a plain URL if none is configured). Instacart and Walmart are only shown where they operate (US/CA).
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a wrap_url handles affiliate ID injection for supported programs.
path-based redirect that doesn't degrade cleanly to a plain URL).
""" """
if not ingredient.strip(): if not ingredient.strip():
return [] return []
links: list[GroceryLink] = [ links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)]
_amazon_fresh_link(ingredient),
_instacart_link(ingredient), if self._locale_cfg["instacart"]:
] links.append(_instacart_link(ingredient, self._locale))
if self._walmart_id:
if self._locale_cfg["walmart"] and self._walmart_id:
links.append(_walmart_link(ingredient, self._walmart_id)) links.append(_walmart_link(ingredient, self._walmart_id))
return links return links

View file

@ -99,6 +99,57 @@
</div> </div>
</section> </section>
<!-- Shopping Locale -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Shopping Region</h3>
<p class="text-sm text-secondary mb-sm">
Sets your Amazon storefront and which retailers appear in shopping links.
Instacart and Walmart are US/CA only other regions get Amazon.
</p>
<select
class="form-input"
v-model="settingsStore.shoppingLocale"
aria-label="Shopping region"
style="max-width: 20rem;"
>
<optgroup label="North America">
<option value="us">United States (USD $)</option>
<option value="ca">Canada (CAD CA$)</option>
<option value="mx">Mexico (MXN MX$)</option>
</optgroup>
<optgroup label="Europe">
<option value="gb">United Kingdom (GBP £)</option>
<option value="de">Germany (EUR )</option>
<option value="fr">France (EUR )</option>
<option value="it">Italy (EUR )</option>
<option value="es">Spain (EUR )</option>
<option value="nl">Netherlands (EUR )</option>
<option value="se">Sweden (SEK kr)</option>
</optgroup>
<optgroup label="Asia Pacific">
<option value="au">Australia (AUD A$)</option>
<option value="nz">New Zealand (NZD NZ$) via Amazon AU</option>
<option value="jp">Japan (JPY ¥)</option>
<option value="in">India (INR )</option>
<option value="sg">Singapore (SGD S$)</option>
</optgroup>
<optgroup label="South America">
<option value="br">Brazil (BRL R$)</option>
</optgroup>
</select>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Display Preferences --> <!-- Display Preferences -->
<section class="mt-md"> <section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Display</h3> <h3 class="text-lg font-semibold mb-xs">Display</h3>

View file

@ -547,6 +547,7 @@ export interface RecipeRequest {
wildcard_confirmed: boolean wildcard_confirmed: boolean
nutrition_filters: NutritionFilters nutrition_filters: NutritionFilters
excluded_ids: number[] excluded_ids: number[]
exclude_ingredients: string[]
shopping_mode: boolean shopping_mode: boolean
pantry_match_only: boolean pantry_match_only: boolean
complexity_filter: string | null complexity_filter: string | null

View file

@ -13,6 +13,7 @@ export const useSettingsStore = defineStore('settings', () => {
// State // State
const cookingEquipment = ref<string[]>([]) const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric') const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us')
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
@ -20,9 +21,10 @@ export const useSettingsStore = defineStore('settings', () => {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const [rawEquipment, rawUnits] = await Promise.allSettled([ const [rawEquipment, rawUnits, rawLocale] = await Promise.allSettled([
settingsAPI.getSetting('cooking_equipment'), settingsAPI.getSetting('cooking_equipment'),
settingsAPI.getSetting('unit_system'), settingsAPI.getSetting('unit_system'),
settingsAPI.getSetting('shopping_locale'),
]) ])
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) { if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
cookingEquipment.value = JSON.parse(rawEquipment.value) cookingEquipment.value = JSON.parse(rawEquipment.value)
@ -30,6 +32,9 @@ export const useSettingsStore = defineStore('settings', () => {
if (rawUnits.status === 'fulfilled' && rawUnits.value) { if (rawUnits.status === 'fulfilled' && rawUnits.value) {
unitSystem.value = rawUnits.value as UnitSystem unitSystem.value = rawUnits.value as UnitSystem
} }
if (rawLocale.status === 'fulfilled' && rawLocale.value) {
shoppingLocale.value = rawLocale.value
}
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load settings:', err) console.error('Failed to load settings:', err)
} finally { } finally {
@ -43,6 +48,7 @@ export const useSettingsStore = defineStore('settings', () => {
await Promise.all([ await Promise.all([
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)), settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
settingsAPI.setSetting('unit_system', unitSystem.value), settingsAPI.setSetting('unit_system', unitSystem.value),
settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
]) ])
saved.value = true saved.value = true
setTimeout(() => { setTimeout(() => {
@ -59,6 +65,7 @@ export const useSettingsStore = defineStore('settings', () => {
// State // State
cookingEquipment, cookingEquipment,
unitSystem, unitSystem,
shoppingLocale,
loading, loading,
saved, saved,