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()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue