feat: eBay category typeahead search for aspect import

Adds live category search so users don't need to know eBay category IDs.
Typing in the search box hits /admin/ebay/category-search, shows name +
breadcrumb path, and auto-saves the selection to the ArticleType on pick.
Aspect import table now only renders after a category is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 20:10:07 +00:00
parent d26c534c34
commit 818c1ec8f7
4 changed files with 229 additions and 56 deletions

View file

@ -66,6 +66,45 @@ final class EbayTaxonomyService
}); });
} }
/**
* Search eBay categories by free-text query (e.g. "Notebook", "RAM", "Switch").
*
* @return list<array{id: string, name: string, path: string}>
*/
public function getCategorySuggestions(string $query): array
{
$token = $this->oauthClient->getAccessToken();
$response = $this->httpClient->request(
'GET',
$this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_category_suggestions',
[
'headers' => [
'Authorization' => 'Bearer '.$token,
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
],
'query' => ['q' => $query],
],
);
/** @var array{categorySuggestions?: list<array{category: array{categoryId: string, categoryName: string}, categoryTreeNodeAncestors?: list<array{categoryName: string}>}>} $data */
$data = $response->toArray();
$results = [];
foreach ($data['categorySuggestions'] ?? [] as $suggestion) {
$ancestors = array_reverse($suggestion['categoryTreeNodeAncestors'] ?? []);
$path = implode(' ', array_column($ancestors, 'categoryName'));
$results[] = [
'id' => $suggestion['category']['categoryId'],
'name' => $suggestion['category']['categoryName'],
'path' => $path,
];
}
return $results;
}
private function getTreeId(): string private function getTreeId(): string
{ {
return match ($this->marketplaceId) { return match ($this->marketplaceId) {

View file

@ -86,6 +86,7 @@ final class ArticleCrudController extends AbstractCrudController
) && !$this->jobRepository->hasActiveJobForArticle($a->getId())); ) && !$this->jobRepository->hasActiveJobForArticle($a->getId()));
return $actions return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $activate) ->add(Crud::PAGE_INDEX, $activate)
->add(Crud::PAGE_INDEX, $markDraft) ->add(Crud::PAGE_INDEX, $markDraft)
->add(Crud::PAGE_INDEX, $rerunAi) ->add(Crud::PAGE_INDEX, $rerunAi)

View file

@ -11,13 +11,13 @@ use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService; use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
final class EbayAspectImportController extends AbstractController final class EbayAspectImportController extends AbstractController
{ {
@ -28,6 +28,25 @@ final class EbayAspectImportController extends AbstractController
) { ) {
} }
#[Route('/admin/ebay/category-search', name: 'admin_ebay_category_search')]
public function searchCategories(Request $request): JsonResponse
{
$q = trim((string) $request->query->get('q', ''));
if (mb_strlen($q) < 2) {
return $this->json([]);
}
try {
$results = $this->taxonomy->getCategorySuggestions($q);
} catch (\Throwable) {
return $this->json([]);
}
return $this->json(array_slice($results, 0, 15));
}
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
#[IsGranted('ROLE_USER')]
public function __invoke(string $id, Request $request): Response public function __invoke(string $id, Request $request): Response
{ {
$articleType = $this->articleTypeRepo->findById(Uuid::fromString($id)); $articleType = $this->articleTypeRepo->findById(Uuid::fromString($id));
@ -35,39 +54,39 @@ final class EbayAspectImportController extends AbstractController
throw $this->createNotFoundException(); throw $this->createNotFoundException();
} }
$categoryId = $articleType->getEbayCategoryId(); // Save category selection (step 1 of the 2-step flow)
if (null === $categoryId) { if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
$this->addFlash('warning', 'Bitte zuerst die eBay Category ID am Artikel-Typ hinterlegen (Edit → eBay Category ID).'); return $this->handleSetCategory($request, $articleType);
return $this->redirectToRoute('easyadmin', [
'crudAction' => 'edit',
'crudControllerFqcn' => ArticleTypeCrudController::class,
'entityId' => $id,
]);
} }
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
return $this->handleImport($request, $articleType); return $this->handleImport($request, $articleType);
} }
$aspects = $this->taxonomy->getCategoryAspects($categoryId); $categoryId = $articleType->getEbayCategoryId();
usort($aspects, static function (array $a, array $b): int {
$tierA = $a['required'] ? 0 : ('RECOMMENDED' === $a['usage'] ? 1 : 2);
$tierB = $b['required'] ? 0 : ('RECOMMENDED' === $b['usage'] ? 1 : 2);
return $tierA <=> $tierB ?: strcmp($a['name'], $b['name']); $rows = [];
}); $allDefs = [];
$counts = ['required' => 0, 'recommended' => 0, 'optional' => 0];
/** @var list<AttributeDefinition> $allDefs */ if (null !== $categoryId) {
$allDefs = $this->em->getRepository(AttributeDefinition::class)->findBy([], ['name' => 'ASC']); $aspects = $this->taxonomy->getCategoryAspects($categoryId);
usort($aspects, static function (array $a, array $b): int {
$tierA = $a['required'] ? 0 : ('RECOMMENDED' === $a['usage'] ? 1 : 2);
$tierB = $b['required'] ? 0 : ('RECOMMENDED' === $b['usage'] ? 1 : 2);
$rows = $this->buildRows($aspects, $allDefs, $articleType); return $tierA <=> $tierB ?: strcmp($a['name'], $b['name']);
});
$counts = [ /** @var list<AttributeDefinition> $allDefs */
'required' => count(array_filter($aspects, static fn (array $a) => $a['required'])), $allDefs = $this->em->getRepository(AttributeDefinition::class)->findBy([], ['name' => 'ASC']);
'recommended' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'RECOMMENDED' === $a['usage'])), $rows = $this->buildRows($aspects, $allDefs, $articleType);
'optional' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'OPTIONAL' === $a['usage'])), $counts = [
]; 'required' => count(array_filter($aspects, static fn (array $a) => $a['required'])),
'recommended' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'RECOMMENDED' === $a['usage'])),
'optional' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'OPTIONAL' === $a['usage'])),
];
}
return $this->render('admin/ebay/aspect_import.html.twig', [ return $this->render('admin/ebay/aspect_import.html.twig', [
'articleType' => $articleType, 'articleType' => $articleType,
@ -75,9 +94,21 @@ final class EbayAspectImportController extends AbstractController
'allDefs' => $allDefs, 'allDefs' => $allDefs,
'counts' => $counts, 'counts' => $counts,
'categoryId' => $categoryId, 'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
]); ]);
} }
private function handleSetCategory(Request $request, ArticleType $articleType): Response
{
$categoryId = trim((string) $request->request->get('categoryId', ''));
if ('' !== $categoryId) {
$articleType->setEbayCategoryId($categoryId);
$this->em->flush();
}
return $this->redirectToRoute('admin_ebay_aspect_import', ['id' => $articleType->getId()->toRfc4122()]);
}
private function handleImport(Request $request, ArticleType $articleType): Response private function handleImport(Request $request, ArticleType $articleType): Response
{ {
if (!$this->isCsrfTokenValid('ebay_aspect_import', (string) $request->request->get('_token'))) { if (!$this->isCsrfTokenValid('ebay_aspect_import', (string) $request->request->get('_token'))) {

View file

@ -2,13 +2,46 @@
{% block page_title %} {% block page_title %}
<i class="fa fa-cloud-download-alt me-2"></i>eBay Aspects importieren — {{ articleType.name }} <i class="fa fa-cloud-download-alt me-2"></i>eBay Aspects importieren — {{ articleType.name }}
<small class="text-muted fw-normal ms-2 fs-6">Kategorie {{ categoryId }}</small>
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<form method="post" id="aspect-import-form"> {# ── Category search / picker ─────────────────────────────────────── #}
<input type="hidden" name="_token" value="{{ csrf_token('ebay_aspect_import') }}"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fa fa-search me-2"></i>eBay-Kategorie</h5>
{% if categoryId %}
<span class="badge bg-info fs-6 font-monospace">ID: {{ categoryId }}</span>
{% endif %}
</div>
<div class="card-body">
<form method="post" id="category-select-form">
<input type="hidden" name="_action" value="set-category">
<input type="hidden" name="categoryId" id="selected-category-id" value="{{ categoryId ?? '' }}">
<div class="position-relative">
<input type="text"
id="category-search-input"
class="form-control"
placeholder="Kategorie suchen, z.B. „Notebook", „RAM", „Switch" …"
value="{{ categoryId ? '(ID ' ~ categoryId ~ ' — tippen um zu ändern)' : '' }}"
autocomplete="off">
<div id="category-dropdown"
class="position-absolute w-100 border rounded bg-white shadow-sm"
style="z-index:1000;display:none;max-height:320px;overflow-y:auto;top:100%;left:0;">
</div>
</div>
{% if not categoryId %}
<div class="text-muted small mt-2">
<i class="fa fa-info-circle me-1"></i>Kategorie wählen, um eBay-Aspekte zu laden.
</div>
{% endif %}
</form>
</div>
</div>
{% if categoryId %}
{# ── Summary bar ──────────────────────────────────────────────────── #} {# ── Summary bar ──────────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@ -16,7 +49,11 @@
<span class="badge bg-danger fs-6">{{ counts.required }} Required</span> <span class="badge bg-danger fs-6">{{ counts.required }} Required</span>
<span class="badge bg-warning text-dark fs-6">{{ counts.recommended }} Recommended</span> <span class="badge bg-warning text-dark fs-6">{{ counts.recommended }} Recommended</span>
<span class="badge bg-secondary fs-6">{{ counts.optional }} Optional</span> <span class="badge bg-secondary fs-6">{{ counts.optional }} Optional</span>
<span class="text-muted small ms-2">Auto-matched {{ rows|filter(r => r.action == 'match')|length }} · Create {{ rows|filter(r => r.action == 'create')|length }} · Skip {{ rows|filter(r => r.action == 'skip')|length }}</span> <span class="text-muted small ms-2">
Auto-matched {{ rows|filter(r => r.action == 'match')|length }} ·
Create {{ rows|filter(r => r.action == 'create')|length }} ·
Skip {{ rows|filter(r => r.action == 'skip')|length }}
</span>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-create">All → Create</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-create">All → Create</button>
@ -25,13 +62,16 @@
class="btn btn-sm btn-outline-secondary"> class="btn btn-sm btn-outline-secondary">
<i class="fa fa-arrow-left me-1"></i>Abbrechen <i class="fa fa-arrow-left me-1"></i>Abbrechen
</a> </a>
<button type="submit" class="btn btn-primary btn-sm"> <button type="submit" form="aspect-import-form" class="btn btn-primary btn-sm">
<i class="fa fa-check me-1"></i>Importieren <i class="fa fa-check me-1"></i>Importieren
</button> </button>
</div> </div>
</div> </div>
{# ── Main table ───────────────────────────────────────────────────── #} {# ── Aspect import form ───────────────────────────────────────────── #}
<form method="post" id="aspect-import-form">
<input type="hidden" name="_token" value="{{ csrf_token('ebay_aspect_import') }}">
<table class="table table-hover align-middle" id="aspects-table"> <table class="table table-hover align-middle" id="aspects-table">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@ -48,7 +88,6 @@
{% set aspect = row.aspect %} {% set aspect = row.aspect %}
<tr class="aspect-row{% if row.alreadyAssigned %} table-success{% endif %}" data-index="{{ i }}"> <tr class="aspect-row{% if row.alreadyAssigned %} table-success{% endif %}" data-index="{{ i }}">
{# Aspect name #}
<td> <td>
<span class="fw-medium">{{ aspect.name }}</span> <span class="fw-medium">{{ aspect.name }}</span>
{% if row.alreadyAssigned %} {% if row.alreadyAssigned %}
@ -56,7 +95,6 @@
{% endif %} {% endif %}
</td> </td>
{# Tier badge #}
<td> <td>
{% if aspect.required %} {% if aspect.required %}
<span class="badge bg-danger">Required</span> <span class="badge bg-danger">Required</span>
@ -67,18 +105,16 @@
{% endif %} {% endif %}
</td> </td>
{# eBay values preview #}
<td> <td>
{% if aspect.values %} {% if aspect.values %}
<span class="text-muted small"> <span class="text-muted small">
{{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} <em>(+{{ aspect.values|length - 6 }} weitere)</em>{% endif %} {{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} <em>(+{{ aspect.values|length - 6 }})</em>{% endif %}
</span> </span>
{% else %} {% else %}
<span class="text-muted small fst-italic">Freitext</span> <span class="text-muted small fst-italic">Freitext</span>
{% endif %} {% endif %}
</td> </td>
{# Action selector #}
<td> <td>
<select name="aspects[{{ i }}][action]" <select name="aspects[{{ i }}][action]"
class="form-select form-select-sm aspect-action" class="form-select form-select-sm aspect-action"
@ -92,9 +128,7 @@
<input type="hidden" name="aspects[{{ i }}][ebayRequired]" value="{{ aspect.required ? '1' : '0' }}"> <input type="hidden" name="aspects[{{ i }}][ebayRequired]" value="{{ aspect.required ? '1' : '0' }}">
</td> </td>
{# Attribute input (match or create) #}
<td> <td>
{# Match: pick existing definition #}
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};"> <div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm"> <select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
{% for def in allDefs %} {% for def in allDefs %}
@ -104,8 +138,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{# Create: name + type #}
<div class="section-create-{{ i }}" style="display:{% if row.action == 'create' %}block{% else %}none{% endif %};"> <div class="section-create-{{ i }}" style="display:{% if row.action == 'create' %}block{% else %}none{% endif %};">
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<input type="text" <input type="text"
@ -114,21 +146,18 @@
class="form-control form-control-sm flex-grow-1" class="form-control form-control-sm flex-grow-1"
placeholder="Attribut-Name"> placeholder="Attribut-Name">
<select name="aspects[{{ i }}][type]" class="form-select form-select-sm" style="width:auto;"> <select name="aspects[{{ i }}][type]" class="form-select form-select-sm" style="width:auto;">
<option value="string" {% if row.suggestedType == 'string' %}selected{% endif %}>Text</option> <option value="string" {% if row.suggestedType == 'string' %}selected{% endif %}>Text</option>
<option value="select" {% if row.suggestedType == 'select' %}selected{% endif %}>Select ({{ aspect.values|length }} Werte)</option> <option value="select" {% if row.suggestedType == 'select' %}selected{% endif %}>Select ({{ aspect.values|length }})</option>
<option value="int">Int</option> <option value="int">Int</option>
<option value="float">Float</option> <option value="float">Float</option>
</select> </select>
</div> </div>
</div> </div>
{# Skip: placeholder #}
<div class="section-skip-{{ i }}" style="display:{% if row.action == 'skip' %}block{% else %}none{% endif %};"> <div class="section-skip-{{ i }}" style="display:{% if row.action == 'skip' %}block{% else %}none{% endif %};">
<span class="text-muted small">—</span> <span class="text-muted small">—</span>
</div> </div>
</td> </td>
{# Required checkbox #}
<td class="text-center"> <td class="text-center">
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};"> <div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
<input type="checkbox" <input type="checkbox"
@ -154,8 +183,85 @@
</form> </form>
{% else %}
<div class="alert alert-info">
<i class="fa fa-arrow-up me-2"></i>Kategorie oben suchen und auswählen, um die eBay-Aspekte zu laden.
</div>
{% endif %}
<script> <script>
(function () { (function () {
const searchInput = document.getElementById('category-search-input');
const dropdown = document.getElementById('category-dropdown');
const hiddenId = document.getElementById('selected-category-id');
const selectForm = document.getElementById('category-select-form');
const searchUrl = {{ searchUrl|json_encode|raw }};
let debounceTimer = null;
searchInput.addEventListener('focus', function () {
if (hiddenId.value) {
this.value = '';
hiddenId.value = '';
}
});
searchInput.addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) { hideDropdown(); return; }
debounceTimer = setTimeout(() => fetchSuggestions(q), 250);
});
document.addEventListener('click', e => {
if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
hideDropdown();
}
});
async function fetchSuggestions(q) {
try {
const res = await fetch(searchUrl + '?q=' + encodeURIComponent(q));
const items = await res.json();
renderDropdown(items);
} catch { hideDropdown(); }
}
function renderDropdown(items) {
if (!items.length) { hideDropdown(); return; }
dropdown.innerHTML = items.map(item => {
const path = item.path ? `<div class="text-muted small">${e(item.path)}</div>` : '';
return `<div class="px-3 py-2 border-bottom category-option" style="cursor:pointer;"
data-id="${e(item.id)}" data-name="${e(item.name)}">
<span class="fw-medium">${e(item.name)}</span>
<span class="badge bg-light text-dark border ms-1">${e(item.id)}</span>
${path}
</div>`;
}).join('');
dropdown.querySelectorAll('.category-option').forEach(opt => {
opt.addEventListener('mouseenter', () => opt.style.background = '#f8f9fa');
opt.addEventListener('mouseleave', () => opt.style.background = '');
opt.addEventListener('click', () => selectCategory(opt.dataset.id, opt.dataset.name));
});
dropdown.style.display = 'block';
}
function selectCategory(id, name) {
searchInput.value = name + ' (ID: ' + id + ')';
hiddenId.value = id;
hideDropdown();
selectForm.submit();
}
function hideDropdown() { dropdown.style.display = 'none'; }
function e(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* ── Aspect table JS ─────────────────────────────────────────── */
function syncRow(index, action) { function syncRow(index, action) {
['match', 'create', 'skip'].forEach(s => { ['match', 'create', 'skip'].forEach(s => {
const el = document.querySelector(`.section-${s}-${index}`); const el = document.querySelector(`.section-${s}-${index}`);
@ -166,22 +272,18 @@
} }
document.querySelectorAll('.aspect-action').forEach(sel => { document.querySelectorAll('.aspect-action').forEach(sel => {
sel.addEventListener('change', function () { sel.addEventListener('change', function () { syncRow(this.dataset.index, this.value); });
syncRow(this.dataset.index, this.value); });
document.getElementById('btn-select-all-create')?.addEventListener('click', () => {
document.querySelectorAll('.aspect-action').forEach(sel => {
sel.value = 'create'; syncRow(sel.dataset.index, 'create');
}); });
}); });
document.getElementById('btn-select-all-create').addEventListener('click', () => { document.getElementById('btn-select-all-skip')?.addEventListener('click', () => {
document.querySelectorAll('.aspect-action').forEach(sel => { document.querySelectorAll('.aspect-action').forEach(sel => {
sel.value = 'create'; sel.value = 'skip'; syncRow(sel.dataset.index, 'skip');
syncRow(sel.dataset.index, 'create');
});
});
document.getElementById('btn-select-all-skip').addEventListener('click', () => {
document.querySelectorAll('.aspect-action').forEach(sel => {
sel.value = 'skip';
syncRow(sel.dataset.index, 'skip');
}); });
}); });
})(); })();