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()
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale"})
class SettingBody(BaseModel):

View file

@ -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,

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).
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

View file

@ -99,6 +99,57 @@
</div>
</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 -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Display</h3>

View file

@ -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

View file

@ -13,6 +13,7 @@ export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('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,