Skip to content

Commit 505bb7b

Browse files
committed
Merge development: register La Bella Toscana; deploy wimpi bundle update.
Resolve duplicate labellatoscana manifest entry; add es/ placeholder and last-scan resync.
2 parents d06cddd + 449d4f8 commit 505bb7b

7 files changed

Lines changed: 298 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver
1010

1111
### Added
1212

13+
- **Marketing / La Bella Toscana:** Registered **satisfecho.de/labellatoscana/es/** — manifest entry for **`060_labellatoscana`** (slug **`labellatoscana`** matches SPA **`baseHref`**; artifact **`labellatoscana-satisfecho-deploy`**; **`deploySubpath`** **`es`**).
1314
- **Marketing / Pizza Luna:** Registered **satisfecho.de/pizzaluna/es/** — manifest entry for **`087_pizzalluna`** (slug **`pizzaluna`** matches SPA **`baseHref`**; artifact **`pizzaluna-satisfecho-deploy`**; **`deploySubpath`** **`es`**).
1415
- **Marketing / Rico Kebab:** Registered **satisfecho.de/rico-kebab/** — manifest entry for **`088_ricokebab`** (slug matches SPA **`baseHref`** `/rico-kebab/`; artifact **`rico-kebab-satisfecho-deploy`**).
1516

@@ -23,6 +24,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver
2324

2425
### Changed
2526

27+
- **Marketing / Wimpi:** Removed carta and booking CTAs from **satisfecho.de/wimpi/es/** per venue request after marketing build and amvara9 deploy (`083_wimpi`).
28+
- **Public menu API:** `GET /public/tenants/{id}/menu` groups sections by **subcategory** when set (e.g. Carta principal, Ensaladas); otherwise by the **localized** standard category label (Desserts → Postres for `lang=es`) — marketing sites and `/public-menu/:id` show restaurant-style section titles instead of raw English category keys.
2629
- **Marketing / Wimpi:** Updated Google reviews copy on **satisfecho.de/wimpi/es/****4,8 / 5 · 239 valoraciones** (was 4,7 / 102) to match the current Google listing after marketing build and amvara9 deploy (`083_wimpi` #2).
2730
- **Agent loop:** Per-step wall-clock limits on **`cursor-agent`** in **`agents2/pos-cursor-loop.sh`** (default **25** minutes; tester **32** minutes for deploy polling) so a hung step does not block the whole cycle — on timeout the orchestrator logs and continues; **`TESTING-`** / **`WIP-`** tasks are retried on the next pass. Disable with **`AGENT_CURSOR_TIMEOUT=0`**.
2831
- **Marketing / Gustazo:** Removed gallery image **`local-04`** from live **satisfecho.de/gustazo/** after **`gustazo-dist`** bundle sync (`040_gustazo` #1).

agents2/005-marketing-repos-reviewer/last-scan.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,26 @@
2020
"last_artifact": "dist",
2121
"pushed_at": "2026-04-21T06:05:35Z"
2222
},
23+
"satisfecho/060_labellatoscana": {
24+
"last_artifact": "labellatoscana-satisfecho-deploy",
25+
"pushed_at": "2026-06-05T11:47:20Z"
26+
},
2327
"satisfecho/082_la_moca": {
2428
"last_artifact": "la-moca-dist",
2529
"pushed_at": "2026-05-29T09:38:56Z"
2630
},
2731
"satisfecho/083_wimpi": {
2832
"last_artifact": "wimpi-satisfecho-deploy",
29-
"pushed_at": "2026-06-02T07:20:19Z"
33+
"pushed_at": "2026-06-05T12:46:54Z"
3034
},
3135
"satisfecho/085_Bosskebabypizzeria": {
3236
"last_artifact": "boss-kebab-satisfecho-deploy",
3337
"pushed_at": "2026-06-02T07:02:46Z"
3438
},
39+
"satisfecho/086_pitahouse": {
40+
"last_artifact": "pita-house-satisfecho-deploy",
41+
"pushed_at": "2026-06-05T06:54:41Z"
42+
},
3543
"satisfecho/087_pizzalluna": {
3644
"last_artifact": "pizzaluna-satisfecho-deploy",
3745
"pushed_at": "2026-06-04T14:08:11Z"

back/app/category_codes.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,111 @@ def get_category_alias_to_canonical() -> dict[str, str]:
187187
return merged
188188

189189

190+
_CATEGORY_DISPLAY_BY_LOCALE: dict[str, dict[str, str]] | None = None
191+
192+
193+
def _normalize_public_menu_lang(lang: str) -> str:
194+
"""Map API lang to a front/public/i18n filename stem."""
195+
raw = (lang or "en").strip().replace("_", "-")
196+
lower = raw.lower()
197+
if lower.startswith("zh"):
198+
return "zh-CN"
199+
if lower.startswith("ca"):
200+
return "ca"
201+
return lower.split("-")[0] or "en"
202+
203+
204+
_BUILTIN_CATEGORY_DISPLAY: dict[str, dict[str, str]] = {
205+
"en": {v: v for v in CATEGORY_CODES.values()},
206+
"es": {
207+
"Starters": "Entrantes",
208+
"Main Course": "Plato principal",
209+
"Desserts": "Postres",
210+
"Beverages": "Bebidas",
211+
"Sides": "Guarniciones",
212+
},
213+
"ca": {
214+
"Starters": "Entrants",
215+
"Main Course": "Plat principal",
216+
"Desserts": "Postres",
217+
"Beverages": "Begudes",
218+
"Sides": "Amanida / Acompanyaments",
219+
},
220+
"de": {
221+
"Starters": "Vorspeisen",
222+
"Main Course": "Hauptgericht",
223+
"Desserts": "Desserts",
224+
"Beverages": "Getränke",
225+
"Sides": "Beilagen",
226+
},
227+
"fr": {
228+
"Starters": "Entrées",
229+
"Main Course": "Plat principal",
230+
"Desserts": "Desserts",
231+
"Beverages": "Boissons",
232+
"Sides": "Accompagnements",
233+
},
234+
}
235+
236+
237+
def _load_category_display_by_locale() -> dict[str, dict[str, str]]:
238+
"""Canonical English category -> localized label per locale."""
239+
out: dict[str, dict[str, str]] = {
240+
locale: dict(labels) for locale, labels in _BUILTIN_CATEGORY_DISPLAY.items()
241+
}
242+
directory = _repo_i18n_dir()
243+
if directory:
244+
for path in sorted(directory.glob("*.json")):
245+
locale = path.stem
246+
try:
247+
data = json.loads(path.read_text(encoding="utf-8"))
248+
except (OSError, json.JSONDecodeError) as exc:
249+
logger.warning("Skipping i18n file %s: %s", path.name, exc)
250+
continue
251+
products = data.get("PRODUCTS")
252+
if not isinstance(products, dict):
253+
continue
254+
labels: dict[str, str] = dict(out.get(locale, {}))
255+
for i18n_key, code_key in _I18N_CATEGORY_KEYS:
256+
label = products.get(i18n_key)
257+
if not isinstance(label, str):
258+
continue
259+
stripped = label.strip()
260+
if not stripped:
261+
continue
262+
labels[CATEGORY_CODES[code_key]] = stripped
263+
if labels:
264+
out[locale] = labels
265+
return out
266+
267+
268+
def get_category_display_by_locale() -> dict[str, dict[str, str]]:
269+
global _CATEGORY_DISPLAY_BY_LOCALE
270+
if _CATEGORY_DISPLAY_BY_LOCALE is None:
271+
_CATEGORY_DISPLAY_BY_LOCALE = _load_category_display_by_locale()
272+
return _CATEGORY_DISPLAY_BY_LOCALE
273+
274+
275+
def get_public_category_display_label(category: str | None, lang: str = "en") -> str:
276+
"""
277+
Localized menu section title for public APIs when grouping by category (no subcategory).
278+
Unknown categories are returned stripped as-is.
279+
"""
280+
if category is None:
281+
return "Other"
282+
stripped = category.strip()
283+
if not stripped:
284+
return "Other"
285+
canonical = normalize_product_category(stripped) or stripped
286+
locale = _normalize_public_menu_lang(lang)
287+
labels = get_category_display_by_locale()
288+
if locale in labels and canonical in labels[locale]:
289+
return labels[locale][canonical]
290+
if canonical in CATEGORY_CODES.values():
291+
return labels.get("en", {}).get(canonical, canonical)
292+
return stripped
293+
294+
190295
def normalize_product_category(category: str | None) -> str | None:
191296
"""
192297
Return canonical English for known standard categories; otherwise strip and return as-is.

back/app/public_tenant_menu.py

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sqlmodel import Session, select
1010

1111
from . import models
12+
from .category_codes import get_public_category_display_label
1213
from .tenant_currency import normalize_tenant_currency_fields
1314
from .translation_service import TranslationService
1415

@@ -259,20 +260,83 @@ def _load_flat_products(
259260
return products
260261

261262

262-
def group_products_into_categories(products: list[dict]) -> list[dict]:
263-
"""Group flat product dicts into categories sorted by name."""
264-
by_category: dict[str, list[dict]] = {}
263+
def _uncategorized_section_label(lang: str) -> str:
264+
"""Localized title for products without category/subcategory."""
265+
labels = {
266+
"en": "Other",
267+
"es": "Otros",
268+
"ca": "Altres",
269+
"de": "Sonstiges",
270+
"fr": "Autres",
271+
}
272+
locale = (lang or "en").strip().lower().split("-")[0]
273+
return labels.get(locale, labels["en"])
274+
275+
276+
def _public_menu_section_title(product: dict, lang: str) -> str:
277+
"""Display section: subcategory when set, else localized standard category label."""
278+
sub = (product.get("subcategory") or "").strip()
279+
if sub:
280+
return sub
281+
raw_category = (product.get("category") or "").strip()
282+
if not raw_category:
283+
return _uncategorized_section_label(lang)
284+
return get_public_category_display_label(raw_category, lang)
285+
286+
287+
def _section_sort_key(display_name: str) -> tuple[int, str]:
288+
"""Prefer common restaurant section order; unknown sections sort alphabetically."""
289+
hints: dict[str, int] = {
290+
"carta principal": 10,
291+
"especialidades": 20,
292+
"entrantes": 30,
293+
"ensaladas": 40,
294+
"starters": 50,
295+
"entrants": 50,
296+
"main course": 60,
297+
"plato principal": 60,
298+
"plat principal": 60,
299+
"beverages": 70,
300+
"bebidas": 70,
301+
"begudes": 70,
302+
"desserts": 80,
303+
"postres": 80,
304+
"sides": 90,
305+
"guarniciones": 90,
306+
"other": 999,
307+
"otros": 999,
308+
}
309+
rank = hints.get(display_name.strip().lower(), 500)
310+
return (rank, display_name.lower())
311+
312+
313+
def group_products_into_categories(products: list[dict], lang: str = "en") -> list[dict]:
314+
"""
315+
Group flat product dicts into menu sections for public/marketing UIs.
316+
317+
Uses subcategory as the section when present; otherwise the localized
318+
standard category label (e.g. Desserts -> Postres for lang=es).
319+
"""
320+
by_section: dict[str, list[dict]] = {}
321+
section_titles: dict[str, str] = {}
322+
265323
for product in products:
266-
category_name = (product.get("category") or "").strip() or _UNCategorized_CATEGORY
267-
by_category.setdefault(category_name, []).append(product)
324+
title = _public_menu_section_title(product, lang)
325+
section_id = _category_slug(title)
326+
section_titles.setdefault(section_id, title)
327+
by_section.setdefault(section_id, []).append(product)
268328

269329
categories = []
270-
for name in sorted(by_category.keys(), key=lambda n: n.lower()):
271-
items = sorted(by_category[name], key=lambda p: (p.get("name") or "").lower())
330+
for section_id in sorted(
331+
by_section.keys(),
332+
key=lambda sid: _section_sort_key(section_titles[sid]),
333+
):
334+
title = section_titles[section_id]
335+
items = sorted(by_section[section_id], key=lambda p: (p.get("name") or "").lower())
272336
categories.append(
273337
{
274-
"id": _category_slug(name),
275-
"name": name,
338+
"id": section_id,
339+
"name": title,
276340
"products": items,
277341
}
278342
)
@@ -301,7 +365,7 @@ def build_public_tenant_menu(
301365
tenant_name = translated
302366

303367
products = _load_flat_products(session, tenant_id, lang, currency_code)
304-
categories = group_products_into_categories(products)
368+
categories = group_products_into_categories(products, lang)
305369

306370
return {
307371
"tenant_id": tenant_id,

back/tests/test_public_tenant_menu.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from pg_client_mixin import PgClientTestCase
88

99
from app import models
10-
from app.public_tenant_menu import assert_no_sensitive_product_fields, format_public_price
10+
from app.public_tenant_menu import (
11+
assert_no_sensitive_product_fields,
12+
format_public_price,
13+
group_products_into_categories,
14+
)
1115

1216

1317
class TestPublicTenantMenu(PgClientTestCase):
@@ -65,8 +69,10 @@ def test_happy_path_grouped_by_category(self):
6569

6670
self.assertEqual(len(data["categories"]), 2)
6771
cat_ids = {c["id"] for c in data["categories"]}
68-
self.assertIn("entrantes", cat_ids)
72+
self.assertIn("starters", cat_ids)
6973
self.assertIn("principales", cat_ids)
74+
by_id = {c["id"]: c for c in data["categories"]}
75+
self.assertEqual(by_id["starters"]["name"], "Starters")
7076

7177
all_products = [p for c in data["categories"] for p in c["products"]]
7278
self.assertEqual(len(all_products), 2)
@@ -78,6 +84,7 @@ def test_happy_path_grouped_by_category(self):
7884
self.assertEqual(olivas["price_formatted"], "2.50")
7985
self.assertEqual(olivas["description"], "Aceitunas")
8086
self.assertEqual(olivas["category"], "Entrantes")
87+
self.assertEqual(data["categories"][0]["name"], "Starters")
8188
self.assertIsNone(olivas["subcategory"])
8289
self.assertTrue(olivas["available"])
8390

@@ -106,6 +113,51 @@ def test_lang_query_es_formats_price_with_comma(self):
106113
self.assertEqual(data["lang"], "es")
107114
product = data["categories"][0]["products"][0]
108115
self.assertEqual(product["price_formatted"], "3,50")
116+
self.assertEqual(data["categories"][0]["name"], "Entrantes")
117+
118+
def test_group_by_subcategory_when_present(self):
119+
self.session.add(
120+
models.Product(
121+
tenant_id=self.tenant.id,
122+
name="Margarita",
123+
price_cents=900,
124+
category="Main Course",
125+
subcategory="Carta principal",
126+
)
127+
)
128+
self.session.add(
129+
models.Product(
130+
tenant_id=self.tenant.id,
131+
name="Calzone",
132+
price_cents=1390,
133+
category="Main Course",
134+
subcategory="Especialidades",
135+
)
136+
)
137+
self.session.add(
138+
models.Product(
139+
tenant_id=self.tenant.id,
140+
name="Panna Cotta",
141+
price_cents=595,
142+
category="Desserts",
143+
)
144+
)
145+
self.session.commit()
146+
147+
response = self.client.get(
148+
f"/public/tenants/{self.tenant.id}/menu",
149+
params={"lang": "es"},
150+
)
151+
self.assertEqual(response.status_code, 200, response.text)
152+
data = response.json()
153+
names = [c["name"] for c in data["categories"]]
154+
self.assertEqual(
155+
names,
156+
["Carta principal", "Especialidades", "Postres"],
157+
)
158+
panna = data["categories"][2]["products"][0]
159+
self.assertEqual(panna["name"], "Panna Cotta")
160+
self.assertIsNone(panna["subcategory"])
109161

110162
def test_lang_from_accept_language_header(self):
111163
self.session.add(
@@ -228,6 +280,24 @@ def test_product_image_url_for_tenant_upload(self):
228280
)
229281

230282

283+
class TestGroupProductsIntoCategories(unittest.TestCase):
284+
def test_unit_grouping_prefers_subcategory(self):
285+
products = [
286+
{
287+
"name": "Sangría",
288+
"category": "Beverages",
289+
"subcategory": None,
290+
},
291+
{
292+
"name": "Margarita",
293+
"category": "Main Course",
294+
"subcategory": "Carta principal",
295+
},
296+
]
297+
sections = group_products_into_categories(products, "es")
298+
self.assertEqual([s["name"] for s in sections], ["Carta principal", "Bebidas"])
299+
300+
231301
class TestFormatPublicPrice(unittest.TestCase):
232302
def test_en_uses_dot(self):
233303
self.assertEqual(format_public_price(250, "en"), "2.50")

0 commit comments

Comments
 (0)