diff --git a/api/app_types/tcgdex.py b/api/app_types/tcgdex.py
new file mode 100644
index 0000000..b0a600f
--- /dev/null
+++ b/api/app_types/tcgdex.py
@@ -0,0 +1,112 @@
+"""TCGdex REST API v2 response shapes (https://tcgdex.dev/)."""
+
+from typing import Any, NotRequired, TypedDict
+
+
+class TcgdexCardCount(TypedDict, total=False):
+ """Card counts inside a set or nested ``set`` on a card."""
+
+ total: int
+ official: int
+ firstEd: int
+ holo: int
+ normal: int
+ reverse: int
+
+
+class TcgdexSetBrief(TypedDict, total=False):
+ """Row from ``GET /v2/{locale}/sets`` (may omit optional fields)."""
+
+ id: str
+ name: str
+ logo: str
+ symbol: str
+ cardCount: TcgdexCardCount
+
+
+class TcgdexAbbreviation(TypedDict, total=False):
+ """Official / internal product codes when present."""
+
+ official: str
+
+
+class TcgdexSerieRef(TypedDict, total=False):
+ """Parent series reference on a set."""
+
+ id: str
+ name: str
+
+
+class TcgdexSeriesBrief(TypedDict, total=False):
+ """Row from ``GET /v2/{locale}/series``."""
+
+ id: str
+ name: str
+ logo: str
+
+
+class TcgdexSeriesDetail(TypedDict, total=False):
+ """Full series from ``GET /v2/{locale}/series/{seriesId}`` (includes nested sets)."""
+
+ id: str
+ name: str
+ logo: str
+ releaseDate: str
+ firstSet: TcgdexSetBrief
+ lastSet: TcgdexSetBrief
+ sets: list[TcgdexSetBrief]
+
+
+class TcgdexSetLegal(TypedDict, total=False):
+ """Play format flags."""
+
+ standard: bool
+ expanded: bool
+
+
+class TcgdexCardInSetBrief(TypedDict, total=False):
+ """Card stub embedded in a full set payload."""
+
+ id: str
+ localId: str
+ name: str
+ image: str
+
+
+class TcgdexSetDetail(TypedDict, total=False):
+ """Full set from ``GET /v2/{locale}/sets/{setId}`` (superset of TcgdexSetBrief)."""
+
+ id: str
+ name: str
+ logo: str
+ symbol: str
+ cardCount: TcgdexCardCount
+ cards: list[TcgdexCardInSetBrief]
+ releaseDate: str
+ serie: TcgdexSerieRef
+ tcgOnline: str
+ abbreviation: TcgdexAbbreviation
+ legal: TcgdexSetLegal
+
+
+class TcgdexSetNestedOnCard(TypedDict, total=False):
+ """``set`` object on a single-card response (subset of set fields)."""
+
+ id: str
+ name: str
+ logo: str
+ symbol: str
+ cardCount: TcgdexCardCount
+
+
+class TcgdexCardDetail(TypedDict, total=False):
+ """Single card from ``GET /v2/{locale}/cards/{cardId}``."""
+
+ id: str
+ localId: str
+ name: str
+ image: str
+ rarity: str
+ category: str
+ set: TcgdexSetNestedOnCard
+ pricing: dict[str, Any]
diff --git a/api/main.py b/api/main.py
index 9e3df55..4d88299 100644
--- a/api/main.py
+++ b/api/main.py
@@ -20,6 +20,7 @@
from routes import access_requests as access_requests_routes
from routes import articles as articles_routes
from routes import auth as auth_routes
+from routes import catalog_route
from routes import ebay_market_route
from routes import ebay_route
from routes import pricing_route
@@ -104,6 +105,7 @@ def health() -> dict[str, str]:
app.include_router(ebay_route.router)
app.include_router(ebay_market_route.router)
app.include_router(pricing_route.router)
+app.include_router(catalog_route.router)
app.include_router(stats_route.router)
app.include_router(scan_routes.router)
app.include_router(shipping_route.router)
diff --git a/api/routes/catalog_route.py b/api/routes/catalog_route.py
new file mode 100644
index 0000000..285e9c2
--- /dev/null
+++ b/api/routes/catalog_route.py
@@ -0,0 +1,341 @@
+"""
+TCGdex-backed catalog routes (series / sets / cards) for stock pickers.
+
+Series group extensions (``GET …/series``). Image URL rules follow
+https://tcgdex.dev/assets (quality + extension for cards;
+``logo.{ext}`` / ``symbol.{ext}`` for sets).
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from core.database import get_db
+from core.deps import get_current_user
+from models.margin_settings import MarginSettings
+from models.user import User
+from services.catalog_prefill_service import build_catalog_card_preview
+from services.tcgdex_asset_url import (
+ enrich_series_brief_row,
+ enrich_series_detail,
+ enrich_set_brief_row,
+ enrich_set_detail,
+)
+from services.tcgdex_client_service import SUPPORTED_LOCALES, TcgdexClientService
+
+router = APIRouter(prefix="/catalog", tags=["catalog"])
+
+
+def _merge_en_series_logos(rows: list[dict[str, Any]], en_rows: list[dict[str, Any]]) -> None:
+ """Fill missing ``logo`` on series rows using EN list (same ``id``)."""
+ logo_by_id: dict[str, str] = {}
+ for er in en_rows:
+ if not isinstance(er, dict):
+ continue
+ eid = er.get("id")
+ if not isinstance(eid, str):
+ continue
+ el = er.get("logo")
+ if isinstance(el, str) and el.strip():
+ logo_by_id[eid] = el.strip()
+ for row in rows:
+ rid = row.get("id")
+ if isinstance(rid, str) and not row.get("logo") and rid in logo_by_id:
+ row["logo"] = logo_by_id[rid]
+
+
+def _merge_en_set_briefs(rows: list[dict[str, Any]], en_rows: list[dict[str, Any]]) -> None:
+ """Fill missing ``logo`` / ``symbol`` on set briefs using EN rows (same ``id``)."""
+ logo_by_id: dict[str, str] = {}
+ sym_by_id: dict[str, str] = {}
+ for er in en_rows:
+ if not isinstance(er, dict):
+ continue
+ eid = er.get("id")
+ if not isinstance(eid, str):
+ continue
+ el = er.get("logo")
+ if isinstance(el, str) and el.strip():
+ logo_by_id[eid] = el.strip()
+ sm = er.get("symbol")
+ if isinstance(sm, str) and sm.strip():
+ sym_by_id[eid] = sm.strip()
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ rid = row.get("id")
+ if not isinstance(rid, str):
+ continue
+ if not row.get("logo") and rid in logo_by_id:
+ row["logo"] = logo_by_id[rid]
+ if not row.get("symbol") and rid in sym_by_id:
+ row["symbol"] = sym_by_id[rid]
+
+
+def _round_eur(value: float | None) -> float | None:
+ if value is None:
+ return None
+ return round(float(value), 2)
+
+
+def _margin_percent(db: Session, user_id: int) -> int:
+ row = db.query(MarginSettings).filter(MarginSettings.user_id == user_id).first()
+ if row is not None:
+ return row.margin_percent
+ return 20
+
+
+@router.get("/sets")
+def list_catalog_sets(
+ user: Annotated[User, Depends(get_current_user)],
+ locale: str = Query("en", min_length=2, max_length=8),
+ page: int = Query(1, ge=1),
+ per_page: int = Query(50, ge=1, le=100),
+ name: str | None = Query(None, max_length=120),
+) -> dict[str, Any]:
+ """Paginated TCGdex set list with optional ``name`` filter."""
+ loc = locale.strip().lower()
+ if loc not in SUPPORTED_LOCALES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}",
+ )
+ client = TcgdexClientService()
+ name_filter = name.strip() if name else None
+ try:
+ rows = client.list_sets(loc, page=page, per_page=per_page, name_contains=name_filter)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ logo_by_id: dict[str, str] = {}
+ sym_by_id: dict[str, str] = {}
+ if loc != "en":
+ try:
+ en_rows = client.list_sets("en", page=page, per_page=per_page, name_contains=name_filter)
+ for er in en_rows:
+ if not isinstance(er, dict):
+ continue
+ eid = er.get("id")
+ if not isinstance(eid, str):
+ continue
+ el = er.get("logo")
+ if isinstance(el, str) and el.strip():
+ logo_by_id[eid] = el.strip()
+ sm = er.get("symbol")
+ if isinstance(sm, str) and sm.strip():
+ sym_by_id[eid] = sm.strip()
+ except RuntimeError:
+ pass
+
+ enriched: list[dict[str, Any]] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ rid = row.get("id")
+ if isinstance(rid, str):
+ if not row.get("logo") and rid in logo_by_id:
+ row["logo"] = logo_by_id[rid]
+ if not row.get("symbol") and rid in sym_by_id:
+ row["symbol"] = sym_by_id[rid]
+ enrich_set_brief_row(row)
+ enriched.append(row)
+
+ return {"locale": loc, "page": page, "per_page": per_page, "sets": enriched}
+
+
+@router.get("/series")
+def list_catalog_series(
+ user: Annotated[User, Depends(get_current_user)],
+ locale: str = Query("en", min_length=2, max_length=8),
+ name: str | None = Query(None, max_length=120),
+) -> dict[str, Any]:
+ """TCGdex series list for grouping extensions; optional ``name`` substring filter (id or name)."""
+ loc = locale.strip().lower()
+ if loc not in SUPPORTED_LOCALES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}",
+ )
+ client = TcgdexClientService()
+ try:
+ rows = client.list_series(loc)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ name_filter = name.strip().lower() if name else ""
+ if name_filter:
+ filtered: list[dict[str, Any]] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ nid = (row.get("id") or "").lower()
+ nname = (row.get("name") or "").lower()
+ if name_filter in nid or name_filter in nname:
+ filtered.append(row)
+ rows = filtered
+
+ if loc != "en":
+ try:
+ en_rows = client.list_series("en")
+ _merge_en_series_logos(rows, en_rows)
+ except RuntimeError:
+ pass
+
+ enriched: list[dict[str, Any]] = []
+ for row in rows:
+ if isinstance(row, dict):
+ enrich_series_brief_row(row)
+ enriched.append(row)
+
+ return {"locale": loc, "series": enriched}
+
+
+@router.get("/series/{series_id}")
+def get_catalog_series(
+ user: Annotated[User, Depends(get_current_user)],
+ series_id: str,
+ locale: str = Query("en", min_length=2, max_length=8),
+) -> dict[str, Any]:
+ """One TCG series with nested set rows (logos merged from EN when needed)."""
+ loc = locale.strip().lower()
+ if loc not in SUPPORTED_LOCALES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}",
+ )
+ client = TcgdexClientService()
+ sid = series_id.strip()
+ try:
+ detail = client.get_series(loc, sid)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ if not isinstance(detail, dict):
+ raise HTTPException(status_code=502, detail="Invalid TCGdex series payload")
+
+ if loc != "en":
+ try:
+ en_detail = client.get_series("en", sid)
+ if isinstance(en_detail, dict):
+ if not detail.get("logo"):
+ el = en_detail.get("logo")
+ if isinstance(el, str) and el.strip():
+ detail["logo"] = el.strip()
+ en_sets = en_detail.get("sets")
+ sets = detail.get("sets")
+ if isinstance(en_sets, list) and isinstance(sets, list):
+ en_list = [s for s in en_sets if isinstance(s, dict)]
+ loc_list = [s for s in sets if isinstance(s, dict)]
+ _merge_en_set_briefs(loc_list, en_list)
+ except RuntimeError:
+ pass
+
+ enrich_series_detail(detail)
+ return {"locale": loc, "series": detail}
+
+
+@router.get("/sets/{set_id}")
+def get_catalog_set(
+ user: Annotated[User, Depends(get_current_user)],
+ set_id: str,
+ locale: str = Query("en", min_length=2, max_length=8),
+) -> dict[str, Any]:
+ """Full TCGdex set payload including card stubs for grid UIs."""
+ loc = locale.strip().lower()
+ if loc not in SUPPORTED_LOCALES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported locale {locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}",
+ )
+ client = TcgdexClientService()
+ try:
+ detail = client.get_set(loc, set_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ if not isinstance(detail, dict):
+ raise HTTPException(status_code=502, detail="Invalid TCGdex set payload")
+
+ if loc != "en":
+ try:
+ en_detail = client.get_set("en", set_id)
+ if isinstance(en_detail, dict):
+ if not detail.get("logo"):
+ el = en_detail.get("logo")
+ if isinstance(el, str) and el.strip():
+ detail["logo"] = el.strip()
+ if not detail.get("symbol"):
+ es = en_detail.get("symbol")
+ if isinstance(es, str) and es.strip():
+ detail["symbol"] = es.strip()
+ except RuntimeError:
+ pass
+
+ enrich_set_detail(detail)
+ return {"locale": loc, "set": detail}
+
+
+@router.get("/card-preview")
+def get_catalog_card_preview(
+ db: Annotated[Session, Depends(get_db)],
+ user: Annotated[User, Depends(get_current_user)],
+ tcgdx_card_id: str = Query(..., min_length=3, max_length=120),
+ pokewallet_set_code: str | None = Query(None, max_length=32),
+ browse_locale: str | None = Query(
+ None,
+ min_length=2,
+ max_length=8,
+ description="Catalog UI locale for the suggested Pokémon name (en, fr, ja).",
+ ),
+) -> dict[str, Any]:
+ """
+ Resolve EN/FR/JA labels from TCGdex, map to PokéWallet codes, and attach pricing preview.
+
+ ``browse_locale`` aligns ``display_pokemon_name`` with the catalogue language.
+ ``suggested_price`` uses the same margin percent as ``GET /pricing/lookup``.
+ """
+ bl = browse_locale.strip().lower() if browse_locale else None
+ if bl and bl not in SUPPORTED_LOCALES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unsupported browse_locale {browse_locale!r}; use one of: {', '.join(sorted(SUPPORTED_LOCALES))}",
+ )
+ try:
+ body = build_catalog_card_preview(
+ tcgdx_card_id=tcgdx_card_id.strip(),
+ pokewallet_set_code=pokewallet_set_code.strip() if pokewallet_set_code else None,
+ browse_locale=bl,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except RuntimeError as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+ if body.get("error"):
+ raise HTTPException(status_code=422, detail=body["error"])
+
+ margin = _margin_percent(db, user.id)
+ pricing_block = body.get("pricing")
+ raw_avg = pricing_block.get("average_price_eur") if isinstance(pricing_block, dict) else None
+ avg = _round_eur(raw_avg if isinstance(raw_avg, (int, float)) else None)
+
+ suggested: float | None = None
+ if avg is not None:
+ suggested = _round_eur(float(avg) * (1.0 + margin / 100.0))
+ lp = body["listing_preview"]
+ if isinstance(lp, dict):
+ lp["suggested_price"] = suggested
+
+ body["margin_percent_used"] = margin
+ return body
diff --git a/api/services/catalog_prefill_service.py b/api/services/catalog_prefill_service.py
new file mode 100644
index 0000000..787fcd2
--- /dev/null
+++ b/api/services/catalog_prefill_service.py
@@ -0,0 +1,155 @@
+"""Build listing fields from TCGdex card data + optional PokéWallet pricing."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app_types.groq_vision import GroqVisionCardCollectorResult
+from app_types.tcgdex import TcgdexCardDetail, TcgdexSetDetail
+from services import pricing_service
+from services.scan_service import build_title_and_description
+from services.species_locale_names_service import fetch_species_locale_names
+from services.tcgdex_client_service import (
+ SUPPORTED_LOCALES,
+ TcgdexClientService,
+ infer_pokewallet_set_code,
+ normalize_card_number_for_pokewallet,
+ split_tcgdex_card_id,
+ tcgdx_image_url_high,
+)
+
+
+def _pick_display_pokemon_name(
+ browse_locale: str | None,
+ name_en: str,
+ name_fr: str,
+ name_ja: str | None,
+) -> str:
+ """Prefer the catalog UI locale, then fall back so the article form matches the picker."""
+ ja_s = (name_ja or "").strip()
+ pool = {"en": name_en.strip(), "fr": name_fr.strip(), "ja": ja_s}
+ raw = (browse_locale or "").strip().lower()
+ loc = raw if raw in SUPPORTED_LOCALES else ""
+ if not loc:
+ order: tuple[str, ...] = ("en", "fr", "ja")
+ elif loc == "ja":
+ order = ("ja", "fr", "en")
+ elif loc == "fr":
+ order = ("fr", "en", "ja")
+ else:
+ order = ("en", "fr", "ja")
+ for key in order:
+ v = pool.get(key, "")
+ if v:
+ return v
+ return name_en or name_fr or "Pokémon"
+
+
+def build_catalog_card_preview(
+ *,
+ tcgdx_card_id: str,
+ pokewallet_set_code: str | None = None,
+ browse_locale: str | None = None,
+ tcgdex: TcgdexClientService | None = None,
+) -> dict[str, Any]:
+ """
+ Load EN/FR/JA card names from TCGdex, infer PokéWallet ``set_code`` + ``card_number``,
+ run pricing lookup, and build title/description compatible with ``ArticleForm``.
+
+ ``browse_locale`` controls which localized Pokémon name is suggested first in
+ ``display_pokemon_name`` (``fr`` | ``en`` | ``ja``).
+ """
+ client = tcgdex or TcgdexClientService()
+ set_id, local_raw = split_tcgdex_card_id(tcgdx_card_id)
+ set_detail = client.get_set("en", set_id)
+ resolved_code = (pokewallet_set_code or "").strip().upper() or infer_pokewallet_set_code(set_detail)
+ if not resolved_code:
+ return {
+ "error": "Could not infer PokéWallet set_code from TCGdex set; pass pokewallet_set_code explicitly.",
+ "tcgdx_card_id": tcgdx_card_id,
+ }
+
+ card_en = client.get_card("en", tcgdx_card_id)
+ try:
+ card_fr = client.get_card("fr", tcgdx_card_id)
+ except (RuntimeError, ValueError):
+ card_fr = card_en
+ try:
+ card_ja = client.get_card("ja", tcgdx_card_id)
+ except (RuntimeError, ValueError):
+ card_ja = card_en
+
+ name_en = (card_en.get("name") or "").strip()
+ name_fr = (card_fr.get("name") or "").strip() or name_en
+ number_pw = normalize_card_number_for_pokewallet(local_raw)
+
+ species = fetch_species_locale_names(name_en)
+ name_tcgdex_ja = (card_ja.get("name") or "").strip()
+ name_ja = name_tcgdex_ja or ((species.japanese or "").strip() or None)
+
+ nested_set = card_en.get("set")
+ set_name_en = ""
+ if isinstance(nested_set, dict):
+ raw_sn = nested_set.get("name")
+ if isinstance(raw_sn, str):
+ set_name_en = raw_sn.strip()
+
+ rarity = ""
+ raw_r = card_en.get("rarity")
+ if isinstance(raw_r, str):
+ rarity = raw_r.strip()
+
+ display_name = _pick_display_pokemon_name(browse_locale, name_en, name_fr, name_ja)
+ ocr: GroqVisionCardCollectorResult = {
+ "set_code": resolved_code,
+ "card_number": number_pw,
+ "pokemon_name": display_name,
+ "pokemon_name_english": name_en,
+ "pokemon_name_french": name_fr,
+ "set_name_english": set_name_en,
+ "rarity_english": rarity,
+ }
+ card_info: dict[str, Any] = {
+ "set_name": set_name_en,
+ "set_code": resolved_code,
+ "card_number": number_pw,
+ "rarity": rarity,
+ }
+ title, description = build_title_and_description(ocr, card_info)
+ if name_ja:
+ j = str(name_ja).strip()
+ if j and j != name_en and j != name_fr:
+ description = f"{description}\nNom (JPN) : {j}"
+
+ pricing = pricing_service.fetch_card_prices(resolved_code, number_pw, name_en)
+ image_base = card_en.get("image")
+ image_url_high: str | None = None
+ if isinstance(image_base, str) and image_base.strip():
+ image_url_high = tcgdx_image_url_high(image_base.strip())
+
+ return {
+ "tcgdx_card_id": tcgdx_card_id,
+ "display_pokemon_name": display_name,
+ "tcgdex": {
+ "names": {"en": name_en, "fr": name_fr, "ja": name_ja},
+ "set_id": set_id,
+ "local_id": local_raw,
+ },
+ "pokewallet": {
+ "set_code": resolved_code,
+ "card_number": number_pw,
+ },
+ "listing_preview": {
+ "title": title,
+ "description": description,
+ "suggested_price": None,
+ },
+ "pricing": {
+ "cardmarket_eur": pricing.get("cardmarket_eur"),
+ "tcgplayer_usd": pricing.get("tcgplayer_usd"),
+ "average_price_eur": pricing.get("average_price"),
+ "error": pricing.get("error"),
+ },
+ "image_url_high": image_url_high,
+ "error": None,
+ }
diff --git a/api/services/tcgdex_asset_url.py b/api/services/tcgdex_asset_url.py
new file mode 100644
index 0000000..641cdbf
--- /dev/null
+++ b/api/services/tcgdex_asset_url.py
@@ -0,0 +1,115 @@
+"""
+Build final TCGdex CDN image URLs.
+
+API responses often omit file extensions on asset bases (e.g. ``…/136``, ``…/logo``).
+Official rules: cards use ``{quality}.{extension}`` (``low`` / ``high`` + ``webp`` recommended);
+logos and symbols use ``logo.{extension}`` and ``symbol.{extension}``.
+
+See: https://tcgdex.dev/assets
+"""
+
+from __future__ import annotations
+
+
+def _has_image_extension(url: str) -> bool:
+ lower = url.rsplit(".", 1)[-1].lower()
+ return lower in ("webp", "png", "jpg", "jpeg")
+
+
+def tcgdx_asset_url_with_webp(url: str | None) -> str | None:
+ """
+ Turn a base path like ``…/swsh3/logo`` or ``…/symbol`` into ``…/logo.webp`` / ``…/symbol.webp``.
+
+ Matches TCGdex assets: logos and symbols are ``logo.{extension}`` and ``symbol.{extension}``.
+ """
+ if url is None:
+ return None
+ raw = url.strip()
+ if not raw:
+ return None
+ if _has_image_extension(raw):
+ return raw
+ return f"{raw}.webp"
+
+
+def normalize_set_logo_url(raw_logo: str | None) -> str | None:
+ """Return ``logo`` with explicit ``.webp`` when the API omits an extension, else ``None``."""
+ if not isinstance(raw_logo, str) or not raw_logo.strip():
+ return None
+ return tcgdx_asset_url_with_webp(raw_logo.strip())
+
+
+def card_image_low_webp(image_base: str | None) -> str | None:
+ """
+ Card preview URL: ``{cardBase}/low.webp`` (``{quality}.{extension}`` per TCGdex assets doc).
+ """
+ if image_base is None:
+ return None
+ base = image_base.strip().rstrip("/")
+ if not base:
+ return None
+ if base.endswith("/low.webp") or "/low." in base:
+ return tcgdx_asset_url_with_webp(base) or base
+ return f"{base}/low.webp"
+
+
+def enrich_series_brief_row(row: dict[str, object]) -> None:
+ """Mutate a TCGdex series row from ``GET …/series``: normalize ``logo`` URL."""
+ raw_logo = row.get("logo")
+ norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None)
+ if norm:
+ row["logo"] = norm
+ else:
+ row.pop("logo", None)
+
+
+def enrich_series_detail(detail: dict[str, object]) -> None:
+ """Normalize series ``logo`` and run ``enrich_set_brief_row`` on each nested set."""
+ raw_logo = detail.get("logo")
+ norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None)
+ if norm:
+ detail["logo"] = norm
+ else:
+ detail.pop("logo", None)
+ sets = detail.get("sets")
+ if not isinstance(sets, list):
+ return
+ for entry in sets:
+ if isinstance(entry, dict):
+ enrich_set_brief_row(entry)
+
+
+def enrich_set_brief_row(row: dict[str, object]) -> None:
+ """Mutate a TCGdex set-brief dict in place: normalize ``logo`` / ``symbol`` URLs."""
+ raw_logo = row.get("logo")
+ norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None)
+ if norm:
+ row["logo"] = norm
+ else:
+ row.pop("logo", None)
+ raw_sym = row.get("symbol")
+ if isinstance(raw_sym, str) and raw_sym.strip():
+ row["symbol"] = tcgdx_asset_url_with_webp(raw_sym.strip()) or raw_sym.strip()
+
+
+def enrich_set_detail(detail: dict[str, object]) -> None:
+ """Mutate a full set payload: ``logo`` / ``symbol`` and add ``image_low`` on each card stub."""
+ raw_logo = detail.get("logo")
+ norm = normalize_set_logo_url(raw_logo if isinstance(raw_logo, str) else None)
+ if norm:
+ detail["logo"] = norm
+ else:
+ detail.pop("logo", None)
+ raw_sym = detail.get("symbol")
+ if isinstance(raw_sym, str) and raw_sym.strip():
+ detail["symbol"] = tcgdx_asset_url_with_webp(raw_sym.strip()) or raw_sym.strip()
+ cards = detail.get("cards")
+ if not isinstance(cards, list):
+ return
+ for entry in cards:
+ if not isinstance(entry, dict):
+ continue
+ img = entry.get("image")
+ low = card_image_low_webp(img if isinstance(img, str) else None)
+ if low:
+ entry["image_low"] = low
diff --git a/api/services/tcgdex_client_service.py b/api/services/tcgdex_client_service.py
new file mode 100644
index 0000000..b578ef2
--- /dev/null
+++ b/api/services/tcgdex_client_service.py
@@ -0,0 +1,196 @@
+"""HTTP client for the TCGdex public REST API (https://api.tcgdex.net/v2)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any, cast
+from urllib.parse import quote, urlencode
+
+import httpx
+
+from app_types.tcgdex import TcgdexCardDetail, TcgdexSeriesDetail, TcgdexSetBrief, TcgdexSetDetail
+
+DEFAULT_BASE_URL = "https://api.tcgdex.net/v2"
+DEFAULT_TIMEOUT_SEC = 30.0
+DEFAULT_USER_AGENT = "GoupixDex/1.0 (+catalog)"
+
+# Locales we actively use in GoupixDex; others may work but are not validated here.
+SUPPORTED_LOCALES = frozenset({"en", "fr", "ja"})
+SETS_PER_PAGE_MAX = 100
+SETS_PER_PAGE_DEFAULT = 50
+
+
+class TcgdexClientService:
+ """
+ Read-only TCGdex client (no API key).
+
+ Endpoints follow ``GET {base}/{locale}/sets`` and ``GET {base}/{locale}/cards/{id}``.
+ """
+
+ def __init__(self, base_url: str | None = None, timeout_sec: float = DEFAULT_TIMEOUT_SEC) -> None:
+ self._base = (base_url or DEFAULT_BASE_URL).rstrip("/")
+ self._timeout = timeout_sec
+
+ def list_sets(
+ self,
+ locale: str,
+ page: int = 1,
+ per_page: int = SETS_PER_PAGE_DEFAULT,
+ name_contains: str | None = None,
+ ) -> list[TcgdexSetBrief]:
+ """
+ List sets for a locale with optional server-side name filter.
+
+ TCGdex returns a JSON array; pagination uses ``pagination:page`` and
+ ``pagination:itemsPerPage`` query keys.
+ """
+ loc = _normalize_locale(locale)
+ params: dict[str, str] = {
+ "pagination:page": str(max(1, page)),
+ "pagination:itemsPerPage": str(max(1, min(SETS_PER_PAGE_MAX, per_page))),
+ }
+ raw_name = (name_contains or "").strip()
+ if raw_name:
+ params["name"] = raw_name
+ query = urlencode(params)
+ path = f"/{quote(loc, safe='')}/sets?{query}"
+ raw = self._fetch_json(path)
+ if not isinstance(raw, list):
+ return []
+ out: list[TcgdexSetBrief] = []
+ for row in raw:
+ if isinstance(row, dict):
+ out.append(cast(TcgdexSetBrief, row))
+ return out
+
+ def get_set(self, locale: str, set_id: str) -> TcgdexSetDetail:
+ """Return full set JSON including the ``cards`` array."""
+ loc = _normalize_locale(locale)
+ sid = set_id.strip()
+ if not sid:
+ msg = "get_set: set_id must be non-empty"
+ raise ValueError(msg)
+ path = f"/{quote(loc, safe='')}/sets/{quote(sid, safe='')}"
+ raw = self._fetch_json(path)
+ if not isinstance(raw, dict):
+ msg = "TCGdex set response was not a JSON object"
+ raise RuntimeError(msg)
+ return cast(TcgdexSetDetail, raw)
+
+ def list_series(self, locale: str) -> list[dict[str, Any]]:
+ """List TCG series for a locale (``GET /{locale}/series``)."""
+ loc = _normalize_locale(locale)
+ path = f"/{quote(loc, safe='')}/series"
+ raw = self._fetch_json(path)
+ if not isinstance(raw, list):
+ return []
+ return [cast(dict[str, Any], row) for row in raw if isinstance(row, dict)]
+
+ def get_series(self, locale: str, series_id: str) -> TcgdexSeriesDetail:
+ """Return full series JSON including the ``sets`` array."""
+ loc = _normalize_locale(locale)
+ sid = series_id.strip()
+ if not sid:
+ msg = "get_series: series_id must be non-empty"
+ raise ValueError(msg)
+ path = f"/{quote(loc, safe='')}/series/{quote(sid, safe='')}"
+ raw = self._fetch_json(path)
+ if not isinstance(raw, dict):
+ msg = "TCGdex series response was not a JSON object"
+ raise RuntimeError(msg)
+ return cast(TcgdexSeriesDetail, raw)
+
+ def get_card(self, locale: str, card_id: str) -> TcgdexCardDetail:
+ """Return a single card by TCGdex id (e.g. ``sv03.5-025``)."""
+ loc = _normalize_locale(locale)
+ cid = card_id.strip()
+ if not cid:
+ msg = "get_card: card_id must be non-empty"
+ raise ValueError(msg)
+ path = f"/{quote(loc, safe='')}/cards/{quote(cid, safe='')}"
+ raw = self._fetch_json(path)
+ if not isinstance(raw, dict):
+ msg = "TCGdex card response was not a JSON object"
+ raise RuntimeError(msg)
+ return cast(TcgdexCardDetail, raw)
+
+ def _fetch_json(self, path: str) -> Any:
+ normalized = path if path.startswith("/") else f"/{path}"
+ url = f"{self._base}{normalized}"
+ headers = {"Accept": "application/json", "User-Agent": DEFAULT_USER_AGENT}
+ response = httpx.get(url, headers=headers, timeout=self._timeout)
+ body_text = response.text
+ if not response.is_success:
+ msg = f"TCGdex request failed ({response.status_code} {response.reason_phrase}): {body_text}"
+ raise RuntimeError(msg)
+ try:
+ return json.loads(body_text)
+ except json.JSONDecodeError as exc:
+ msg = "TCGdex response was not valid JSON"
+ raise RuntimeError(msg) from exc
+
+
+def _normalize_locale(locale: str) -> str:
+ loc = locale.strip().lower()
+ if loc not in SUPPORTED_LOCALES:
+ msg = f"Unsupported TCGdex locale: {locale!r} (supported: {', '.join(sorted(SUPPORTED_LOCALES))})"
+ raise ValueError(msg)
+ return loc
+
+
+def split_tcgdex_card_id(card_id: str) -> tuple[str, str]:
+ """
+ Split ``{setId}-{localId}`` into set and local number.
+
+ Uses rsplit so set ids containing hyphens (e.g. ``sv03.5-025``) still work.
+ """
+ raw = card_id.strip()
+ if "-" not in raw:
+ msg = f"Invalid TCGdex card id (no hyphen): {card_id!r}"
+ raise ValueError(msg)
+ set_part, local = raw.rsplit("-", 1)
+ set_part = set_part.strip()
+ local = local.strip()
+ if not set_part or not local:
+ msg = f"Invalid TCGdex card id: {card_id!r}"
+ raise ValueError(msg)
+ return set_part, local
+
+
+def infer_pokewallet_set_code(set_detail: TcgdexSetDetail) -> str | None:
+ """
+ Best-effort Cardmarket / PokéWallet style set code from TCGdex set JSON.
+
+ Prefer ``abbreviation.official`` (e.g. ``MEW``), then ``tcgOnline``, then uppercased ``id``.
+ """
+ abbrev = set_detail.get("abbreviation")
+ if isinstance(abbrev, dict):
+ off = abbrev.get("official")
+ if isinstance(off, str) and off.strip():
+ return off.strip().upper()
+ tcg_online = set_detail.get("tcgOnline")
+ if isinstance(tcg_online, str) and tcg_online.strip():
+ return tcg_online.strip().upper()
+ sid = set_detail.get("id")
+ if isinstance(sid, str) and sid.strip():
+ return sid.strip().upper()
+ return None
+
+
+def normalize_card_number_for_pokewallet(local_id: str) -> str:
+ """Strip useless leading zeros when the local id is purely numeric."""
+ s = local_id.strip()
+ if s.isdigit():
+ stripped = s.lstrip("0")
+ return stripped if stripped else "0"
+ return s
+
+
+def tcgdx_image_url_high(image_base: str) -> str:
+ """
+ Full card art URL for ``high`` + ``webp`` (pattern ``{quality}.{extension}``).
+
+ https://tcgdex.dev/assets
+ """
+ base = image_base.rstrip("/")
+ return f"{base}/high.webp"
diff --git a/web/app/components/articles/ArticleForm.vue b/web/app/components/articles/ArticleForm.vue
index f85b2a2..6d5369a 100644
--- a/web/app/components/articles/ArticleForm.vue
+++ b/web/app/components/articles/ArticleForm.vue
@@ -144,21 +144,21 @@ watch(
pokemonName.value = a.pokemon_name ?? ''
setCode.value = a.set_code ?? ''
cardNumber.value = a.card_number ?? ''
- condition.value =
- a.condition && conditionOptions.some((o) => o.value === a.condition)
+ condition.value
+ = a.condition && conditionOptions.some(o => o.value === a.condition)
? a.condition
: 'Near Mint'
purchasePrice.value = String(a.purchase_price)
sellPrice.value = a.sell_price != null ? String(a.sell_price) : ''
isGraded.value = Boolean(a.is_graded)
- gradedGraderValueId.value =
- a.graded_grader_value_id
- && EBAY_PROFESSIONAL_GRADER_OPTIONS.some(o => o.value === a.graded_grader_value_id)
+ gradedGraderValueId.value
+ = a.graded_grader_value_id
+ && EBAY_PROFESSIONAL_GRADER_OPTIONS.some(o => o.value === a.graded_grader_value_id)
? a.graded_grader_value_id
: ''
- gradedGradeValueId.value =
- a.graded_grade_value_id
- && EBAY_GRADE_OPTIONS.some(o => o.value === a.graded_grade_value_id)
+ gradedGradeValueId.value
+ = a.graded_grade_value_id
+ && EBAY_GRADE_OPTIONS.some(o => o.value === a.graded_grade_value_id)
? a.graded_grade_value_id
: ''
gradedCertNumber.value = a.graded_cert_number ?? ''
@@ -227,6 +227,53 @@ function applyScanPrefill(scan: {
* of the fields (title, price, condition, …) and optionally a remote image URL
* that we try to fetch as a File so it is bundled with the article creation.
*/
+/**
+ * Prefill from TCGdex catalog + server ``/catalog/card-preview`` (PokéWallet pricing).
+ */
+async function applyCatalogPrefill(p: {
+ display_pokemon_name?: string
+ listing_preview: { title: string, description: string, suggested_price: number | null }
+ pokewallet: { set_code: string, card_number: string }
+ tcgdex: { names: { en: string, fr: string, ja: string | null } }
+ image_url_high?: string | null
+}) {
+ assignTitleFromExternal(p.listing_preview.title)
+ description.value = p.listing_preview.description
+ pokemonName.value = p.display_pokemon_name?.trim()
+ || p.tcgdex.names.ja?.trim()
+ || p.tcgdex.names.en
+ || p.tcgdex.names.fr
+ setCode.value = p.pokewallet.set_code
+ cardNumber.value = p.pokewallet.card_number
+ if (p.listing_preview.suggested_price != null) {
+ purchasePrice.value = String(p.listing_preview.suggested_price)
+ }
+ isGraded.value = false
+ gradedGraderValueId.value = ''
+ gradedGradeValueId.value = ''
+ gradedCertNumber.value = ''
+ importIsSold.value = false
+ importSoldAt.value = null
+ wardrobeVintedListed.value = false
+ wardrobeVintedPublishedAtIso.value = null
+ wardrobeImportSoldPrice.value = null
+ const img = p.image_url_high
+ if (img) {
+ try {
+ const blob = await blobFromVintedPhotoUrl(img)
+ if (blob) {
+ const ext = blob.type.includes('png') ? 'png' : 'webp'
+ const file = new File([blob], `tcgdex-${Date.now()}.${ext}`, {
+ type: blob.type || 'image/webp'
+ })
+ addImageFiles([file])
+ }
+ } catch {
+ /* optional image */
+ }
+ }
+}
+
async function applyEbayPrefill(p: {
title?: string
description?: string
@@ -327,7 +374,8 @@ defineExpose({
addImageFiles,
buildCreateFormData,
applyWardrobeSlot,
- applyEbayPrefill
+ applyEbayPrefill,
+ applyCatalogPrefill
})
function buildCreateFormData(): FormData {
diff --git a/web/app/composables/useCardCatalog.ts b/web/app/composables/useCardCatalog.ts
new file mode 100644
index 0000000..90f0a5c
--- /dev/null
+++ b/web/app/composables/useCardCatalog.ts
@@ -0,0 +1,148 @@
+/** TCGdex-backed catalog API (authenticated proxy through GoupixDex). */
+
+export type CatalogLocale = 'en' | 'fr' | 'ja'
+
+export interface TcgdexSetBrief {
+ id: string
+ name: string
+ logo?: string
+ symbol?: string
+ cardCount?: { total?: number, official?: number }
+}
+
+/** Row from ``GET /catalog/series`` (TCGdex ``/series``). */
+export interface TcgdexSeriesBrief {
+ id: string
+ name: string
+ logo?: string
+}
+
+/** Full series with nested extensions from ``GET /catalog/series/:id``. */
+export interface TcgdexSeriesDetail extends TcgdexSeriesBrief {
+ releaseDate?: string
+ sets?: TcgdexSetBrief[]
+}
+
+export interface CatalogSeriesListResponse {
+ locale: string
+ series: TcgdexSeriesBrief[]
+}
+
+export interface CatalogSeriesDetailResponse {
+ locale: string
+ series: TcgdexSeriesDetail
+}
+
+export interface TcgdexCardInSetBrief {
+ id: string
+ localId: string
+ name: string
+ image?: string
+ /** Ready-to-use thumbnail URL (``…/low.webp``), set by GoupixDex ``GET /catalog/sets/:id``. */
+ image_low?: string
+}
+
+export interface TcgdexSetDetail extends TcgdexSetBrief {
+ cards?: TcgdexCardInSetBrief[]
+ releaseDate?: string
+ serie?: { id?: string, name?: string }
+ abbreviation?: { official?: string }
+ tcgOnline?: string
+}
+
+export interface CatalogSetsResponse {
+ locale: string
+ page: number
+ per_page: number
+ sets: TcgdexSetBrief[]
+}
+
+export interface CatalogSetResponse {
+ locale: string
+ set: TcgdexSetDetail
+}
+
+export interface CatalogCardPreviewResponse {
+ tcgdx_card_id: string
+ /** Pokémon name to show in the form (matches ``browse_locale`` when set). */
+ display_pokemon_name?: string
+ tcgdex: {
+ names: { en: string, fr: string, ja: string | null }
+ set_id: string
+ local_id: string
+ }
+ pokewallet: { set_code: string, card_number: string }
+ listing_preview: { title: string, description: string, suggested_price: number | null }
+ pricing: {
+ cardmarket_eur: number | null
+ tcgplayer_usd: number | null
+ average_price_eur: number | null
+ error: string | null
+ }
+ image_url_high: string | null
+ margin_percent_used: number
+ error?: string | null
+}
+
+export function useCardCatalog() {
+ const { $api } = useNuxtApp()
+
+ async function listSeries(params: { locale: CatalogLocale, name?: string }) {
+ const { data } = await $api.get
+ Données TCGdex · prix PokéWallet
+
+ Parcourez d’abord les séries (Bloc, Écarlate et Violet…),
+ ouvrez une extension puis une carte : le formulaire se remplit
+ (titre, description, code set, numéro, image HD). La recherche filtre séries et extensions.
+ Les tarifs Cardmarket / TCGPlayer ne sont demandés à PokéWallet qu’au moment du clic.
+
+ Parcours guidé
+
+ Catalogue et fiche article
+
+ Extension sélectionnée : {{ selectedSet.name }}
+
+ Série : {{ activeSeriesName }}
+
+ {{ browseSubtitle }}
+
+ Chargement du catalogue…
+
+ Chargement des extensions…
+
+ Chargement des cartes…
+
+ {{ c.name }}
+
+ #{{ c.localId }}
+
+ {{ s.name }}
+
+ {{ s.id }} · {{ s.cardCount?.official ?? s.cardCount?.total ?? '?' }} cartes
+
+ {{ ser.name }}
+
+ {{ ser.id }}
+
+ Séries
+
+ {{ ser.name }}
+
+ {{ ser.id }}
+
+ Extensions
+
+ {{ s.name }}
+
+ {{ s.id }} · {{ s.cardCount?.official ?? s.cardCount?.total ?? '?' }} cartes
+
+ Même formulaire que « Nouvel article » : photos, prix, Vinted / eBay si disponibles
+
+ Données cartes et visuels :
+ TCGdex
+ · Images :
+ règles des assets
+
+ Ajouter une carte depuis le catalogue officiel
+
+
+ {{ browseTitle }}
+
+
+
+
+
+
+
+ Fiche article
+
+