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:
parent
f1d35dd1ac
commit
b223325d77
6 changed files with 100 additions and 21 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,18 +24,26 @@ 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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue