Files
descrybe/apps/web/src/routes/feeds/+page.svelte
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

1425 lines
46 KiB
Svelte

<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Check,
Copy,
FileText,
Link as LinkIcon,
Plus,
RefreshCw,
Search,
Upload
} from "@lucide/svelte";
import { api, ApiError, failureMessage } from "$lib/api";
import { trackEvent } from "$lib/analytics";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import {
unwrapList,
unwrapTotal,
DEFAULT_PAGE_SIZE,
pageOffset,
SEARCH_DEBOUNCE_MS,
debounce,
isAbortError
} from "$lib/list";
import { formatDate, formatDateTime, formatRelativeTime } from "$lib/utils";
import { notifySuccess, notifyError, notifyApiError } from "$lib/notify";
import { isActiveProcessingJob } from "$lib/job-status";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { ListResponse, MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte";
import Spinner from "$lib/components/Spinner.svelte";
/** Client wait for queued feed sync (enqueue + poll until terminal). */
const FEED_SYNC_CLIENT_TIMEOUT_MS = 180_000;
const FEED_SYNC_POLL_MS = 1_500;
import {
Badge,
Button,
Card,
Dialog,
Input,
Label,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "$lib/components/ui";
import EmptyState from "$lib/components/EmptyState.svelte";
import FeedStats from "$lib/components/feeds/FeedStats.svelte";
import FeedActionsMenu from "$lib/components/feeds/FeedActionsMenu.svelte";
import FeedFormatHelp from "$lib/components/feeds/FeedFormatHelp.svelte";
import FtpMigrateNotice from "$lib/components/feeds/FtpMigrateNotice.svelte";
import ProductPagination from "$lib/components/products/ProductPagination.svelte";
import {
canSyncFeed,
mappingTone,
nextSortState,
parseSyncIntervalMinutes,
sortFeedListRows,
syncJobProductsLabel,
syncJobStatusTone
} from "$lib/feeds-list-controls";
import {
ftpSyncUnsupportedMessage,
ftpSyncUnsupportedShortMessage,
feedHasMappings,
feedLastDataAt,
feedMappingFieldCount,
feedProductCount,
feedStatusLabel,
isFeedActive,
isFeedMappedOnly,
isFeedSyncing,
isFtpFeed,
normalizeMappingRows,
shortenUrl,
type FeedRow,
type MappingsPayload,
type SortDirection,
type SortField,
type SyncJob,
type TargetField
} from "$lib/components/feeds/types";
import { STANDARD_FIELDS, toTargetFields } from "$lib/components/feeds/standard-fields";
import {
evaluateMappingPreflight,
formatPreflightMessage,
preflightBlocksSync
} from "$lib/components/feeds/suggest-mappings";
let feeds = $state<FeedRow[]>([]);
let total = $state(0);
let activeTotal = $state(0);
let mappedTotal = $state(0);
let productTotal = $state(0);
let processedTotal = $state(0);
let unprocessedTotal = $state(0);
let error = $state("");
let fieldErrors = $state<Record<string, string>>({});
let success = $state("");
const FEEDS_PAGE_ERROR_ID = "feeds-page-error";
const FEEDS_FORM_ERROR_ID = "feeds-form-error";
const FEED_FIELD_HINTS = {
// Prefer stable codes when API adds them. English needles are last-resort only.
name: ["name", "required"],
url: ["url", "feed url", "invalid"],
file: ["file", "csv", "upload"]
} as const;
let loading = $state(true);
let canAdmin = $state(false);
let searchQuery = $state("");
let appliedSearch = $state("");
let listPage = $state(1);
let sortField = $state<SortField>("name");
let sortDirection = $state<SortDirection>("asc");
let copiedUrl = $state<string | null>(null);
let syncingIds = $state<Record<string, boolean>>({});
/** Feed ids blocked by client mapping preflight (missing required targets). */
let mappingIncompleteIds = $state<Record<string, boolean>>({});
let syncAborts = $state<Record<string, AbortController>>({});
let feedsAbort: AbortController | null = null;
let feedsFetchGen = 0;
let historyAbort: AbortController | null = null;
let enabledFields = $state<TargetField[]>(STANDARD_FIELDS);
let standardFieldsLoaded = $state(false);
let showAdd = $state(false);
let activationFocus = $state<"map" | "sync" | null>(null);
let showEdit = $state(false);
let showHistory = $state(false);
let saving = $state(false);
let editing = $state<FeedRow | null>(null);
let historyFeed = $state<FeedRow | null>(null);
let historyJobs = $state<SyncJob[]>([]);
let historyLoading = $state(false);
let formName = $state("");
let formUrl = $state("");
let formType = $state("xml");
let formInterval = $state("60");
let formSource = $state<"url" | "file">("url");
let formFile = $state<File | null>(null);
let formFileInput = $state<HTMLInputElement | null>(null);
function feedSourceLabel(feed: FeedRow): string {
const opts = feed.options;
if (opts && typeof opts === "object") {
const name = opts.source_filename;
if (typeof name === "string" && name.trim()) return name.trim();
}
return String(feed.url ?? "");
}
function isFileFeed(feed: FeedRow): boolean {
const opts = feed.options;
if (!opts || typeof opts !== "object") return false;
return typeof opts.source_path === "string" && Boolean(String(opts.source_path).trim());
}
const stats = $derived.by(() => ({
total,
active: activeTotal,
mapped: mappedTotal,
products: productTotal,
processed: processedTotal,
unprocessed: unprocessedTotal
}));
const totalPages = $derived(Math.max(1, Math.ceil(total / DEFAULT_PAGE_SIZE)));
const filtered = $derived.by(() => sortFeedListRows(feeds, sortField, sortDirection));
function clearFormFeedback() {
error = "";
fieldErrors = {};
success = "";
}
function applyApiFormError(err: unknown, fallback: string): string {
const result = apiFormError(err, fallback, FEED_FIELD_HINTS);
fieldErrors = result.fields;
return notifyApiError(err, fallback);
}
async function load() {
feedsAbort?.abort();
const ac = new AbortController();
feedsAbort = ac;
const gen = ++feedsFetchGen;
loading = true;
error = "";
try {
const params = new URLSearchParams({
limit: String(DEFAULT_PAGE_SIZE),
offset: String(pageOffset(listPage))
});
if (appliedSearch.trim()) params.set("q", appliedSearch.trim());
const payload = await api<
ListResponse<FeedRow> & {
total?: number;
active_total?: number;
mapped_total?: number;
product_total?: number;
processed_total?: number;
unprocessed_total?: number;
}
>(`/api/feeds?${params}`, { signal: ac.signal });
if (gen !== feedsFetchGen) return;
feeds = unwrapList(payload);
total = unwrapTotal(payload) ?? feeds.length;
const totals = payload as {
active_total?: number;
mapped_total?: number;
product_total?: number;
processed_total?: number;
unprocessed_total?: number;
};
activeTotal =
typeof totals.active_total === "number"
? totals.active_total
: feeds.filter((f) => isFeedSyncing(f)).length;
mappedTotal =
typeof totals.mapped_total === "number"
? totals.mapped_total
: feeds.filter((f) => isFeedMappedOnly(f)).length;
productTotal = typeof totals.product_total === "number" ? totals.product_total : 0;
processedTotal = typeof totals.processed_total === "number" ? totals.processed_total : 0;
unprocessedTotal =
typeof totals.unprocessed_total === "number" ? totals.unprocessed_total : 0;
applyMappingIncompleteFromList(feeds);
} catch (err) {
if (isAbortError(err) || gen !== feedsFetchGen) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("feeds.loadFailed"));
feeds = [];
total = 0;
activeTotal = 0;
mappedTotal = 0;
productTotal = 0;
processedTotal = 0;
unprocessedTotal = 0;
} finally {
if (gen === feedsFetchGen) loading = false;
}
}
const scheduleSearch = debounce(() => {
appliedSearch = searchQuery;
listPage = 1;
void load();
}, SEARCH_DEBOUNCE_MS);
function onSearchInput(value: string) {
searchQuery = value;
if (value.trim() === "") {
scheduleSearch.cancel();
appliedSearch = "";
listPage = 1;
void load();
return;
}
scheduleSearch();
}
function changePage(next: number) {
listPage = next;
void load();
}
onMount(() => {
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
await load().then(() => {
consumeAddQuery();
consumeFocusQuery();
});
})();
return () => {
scheduleSearch.cancel();
feedsAbort?.abort();
historyAbort?.abort();
for (const ac of Object.values(syncAborts)) ac.abort();
};
});
function sleep(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.reject(new DOMException("Aborted", "AbortError"));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function pollFeedSyncJob(feedId: string, jobId: string, signal: AbortSignal): Promise<SyncJob> {
let job = await api<SyncJob>(`/api/feeds/${feedId}/sync-jobs/${jobId}`, { signal });
while (isActiveProcessingJob(job.status)) {
await sleep(FEED_SYNC_POLL_MS, signal);
job = await api<SyncJob>(`/api/feeds/${feedId}/sync-jobs/${jobId}`, { signal });
}
return job;
}
async function ensureStandardFields() {
if (standardFieldsLoaded) return;
try {
const fieldsRes = await api<ListResponse<Record<string, unknown>>>(
"/api/standard-fields?limit=2000"
);
enabledFields = toTargetFields(unwrapList(fieldsRes), {
includeDisabled: true,
mergeFallback: true
});
} catch {
enabledFields = STANDARD_FIELDS;
} finally {
standardFieldsLoaded = true;
}
}
function feedItemPath(feed: FeedRow, normalizedItemPath: string): string {
if (normalizedItemPath.trim()) return normalizedItemPath.trim();
const opts = feed.options && typeof feed.options === "object" ? feed.options : null;
return opts && typeof opts.item_path === "string" ? String(opts.item_path).trim() : "";
}
function mappingPreflightIssues(feed: FeedRow, mapRes: MappingsPayload) {
const known = enabledFields.map((f) => f.value);
const normalized = normalizeMappingRows(mapRes.mappings, known);
return evaluateMappingPreflight({
itemPath: feedItemPath(feed, normalized.itemPath),
rows: normalized.rows,
enabledTargets: enabledFields,
feedType: feed.feed_type
});
}
/** Sync incomplete chips from list payload (server batch; no per-feed mapping fetches). */
function applyMappingIncompleteFromList(list: FeedRow[]) {
const next: Record<string, boolean> = { ...mappingIncompleteIds };
for (const feed of list) {
const id = String(feed.id);
if (!isFeedMappedOnly(feed) || isFtpFeed(feed)) {
delete next[id];
continue;
}
if (feed.mapping_incomplete === true) next[id] = true;
else delete next[id];
}
mappingIncompleteIds = next;
}
async function assertSyncMappingReady(feed: FeedRow): Promise<boolean> {
const id = String(feed.id);
const mappingHref = `/feeds/${id}/mapping`;
await ensureStandardFields();
let mapRes: MappingsPayload;
try {
mapRes = await api<MappingsPayload>(`/api/feeds/${id}/mappings`);
} catch {
const message = i18n.t("toast.feed.mappingLoadFailed");
mappingIncompleteIds = { ...mappingIncompleteIds, [id]: true };
// Toast-only — avoid Alert + toast duplicate for the same gate message.
notifyError(message, {
actions: [{ label: i18n.t("feeds.mapFields"), href: mappingHref }]
});
return false;
}
const issues = mappingPreflightIssues(feed, mapRes);
if (!preflightBlocksSync(issues)) {
if (mappingIncompleteIds[id]) {
const next = { ...mappingIncompleteIds };
delete next[id];
mappingIncompleteIds = next;
}
return true;
}
mappingIncompleteIds = { ...mappingIncompleteIds, [id]: true };
notifyError(formatPreflightMessage(issues), {
actions: [{ label: i18n.t("feeds.mapFields"), href: mappingHref }]
});
return false;
}
function handleSort(field: SortField) {
const next = nextSortState(sortField, sortDirection, field);
sortField = next.field;
sortDirection = next.direction;
}
function sortIcon(field: SortField) {
if (sortField !== field) return ArrowUpDown;
return sortDirection === "asc" ? ArrowUp : ArrowDown;
}
const NameSortIcon = $derived(sortIcon("name"));
const MappingSortIcon = $derived(sortIcon("mapping"));
const ProductsSortIcon = $derived(sortIcon("products"));
const SyncedSortIcon = $derived(sortIcon("lastSynced"));
function lastDataTooltip(feed: FeedRow): string {
const live = typeof feed.last_synced_at === "string" ? feed.last_synced_at.trim() : "";
const products =
typeof feed.products_updated_at === "string" ? feed.products_updated_at.trim() : "";
if (live) return i18n.t("feeds.lastLiveSync", { when: formatDateTime(live) });
if (products) {
return i18n.t("feeds.noLiveSyncProducts", { when: formatDateTime(products) });
}
return i18n.t("feeds.noDataYet");
}
async function copyUrl(url: string, id: string) {
try {
await navigator.clipboard.writeText(url);
copiedUrl = id;
setTimeout(() => {
if (copiedUrl === id) copiedUrl = null;
}, 2000);
} catch {
error = i18n.t("flash.feed.copyFailed");
notifyError(i18n.t("toast.feed.copyFailed"));
}
}
function openAdd(source: "url" | "file" = "url") {
formName = "";
formUrl = "";
formType = source === "file" ? "csv" : "xml";
formInterval = "60";
formSource = source;
formFile = null;
if (formFileInput) formFileInput.value = "";
showAdd = true;
clearFormFeedback();
}
function consumeAddQuery() {
const params = page.url.searchParams;
if (params.get("add") !== "1") return;
const source = params.get("source") === "file" ? "file" : "url";
openAdd(source);
const next = new URL(page.url);
next.searchParams.delete("add");
next.searchParams.delete("source");
const qs = next.searchParams.toString();
void goto(`${next.pathname}${qs ? `?${qs}` : ""}`, { replaceState: true, noScroll: true });
}
function consumeFocusQuery() {
const focus = page.url.searchParams.get("focus");
if (focus !== "map" && focus !== "sync") return;
activationFocus = focus;
if (feeds.length === 0 && !showAdd) {
openAdd();
}
const next = new URL(page.url);
next.searchParams.delete("focus");
const qs = next.searchParams.toString();
void goto(`${next.pathname}${qs ? `?${qs}` : ""}`, { replaceState: true, noScroll: true });
queueMicrotask(() => {
const sel =
focus === "map"
? '[data-tour="feed-open-mapping"]'
: '[data-tour="feed-sync-now"],[data-tour="feeds-empty-add"],[data-tour="feeds-add"]';
document.querySelector<HTMLElement>(sel)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
}
function onFormSourceChange(value: string) {
formSource = value === "file" ? "file" : "url";
if (formSource === "file") {
formType = "csv";
}
}
function onCsvSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0] ?? null;
if (file && !file.name.toLowerCase().endsWith(".csv")) {
formFile = null;
input.value = "";
error = i18n.t("flash.feed.csvRequired");
fieldErrors = { file: error };
notifyError(error);
return;
}
formFile = file;
error = "";
fieldErrors = {};
}
function openEdit(feed: FeedRow) {
editing = feed;
formName = String(feed.name ?? "");
formUrl = String(feed.url ?? "");
formType = String(feed.feed_type ?? "xml");
formInterval = String(feed.sync_interval_minutes ?? 60);
showEdit = true;
clearFormFeedback();
}
async function createFeed(event: Event) {
event.preventDefault();
saving = true;
clearFormFeedback();
try {
let created: FeedRow | null = null;
if (formSource === "file") {
if (!formFile) {
error = i18n.t("flash.feed.csvFileRequired");
fieldErrors = { file: error };
notifyError(error);
return;
}
const body = new FormData();
body.append("name", formName.trim());
body.append("feed_type", "csv");
body.append("sync_interval_minutes", String(parseSyncIntervalMinutes(formInterval)));
body.append("file", formFile);
created = await api<FeedRow>("/api/feeds", { method: "POST", body });
} else {
const url = formUrl.trim();
if (!url) {
error = i18n.t("flash.feed.urlRequired");
fieldErrors = { url: error };
notifyError(error);
return;
}
created = await api<FeedRow>("/api/feeds", {
method: "POST",
body: {
name: formName.trim(),
url,
feed_type: formType,
sync_interval_minutes: parseSyncIntervalMinutes(formInterval)
}
});
}
showAdd = false;
const createdId = created?.id != null ? String(created.id) : "";
trackEvent("feed_created", {
feed_type: formSource === "file" ? "csv" : formType
});
success = createdId ? i18n.t("feeds.createdMapHint") : i18n.t("feeds.createdPlain");
notifySuccess(i18n.t("toast.feed.created"), {
description: i18n.t("feeds.createdMapDesc"),
actions: createdId
? [{ label: i18n.t("feeds.mapFields"), href: `/feeds/${createdId}/mapping` }]
: undefined,
duration: 12000
});
await load();
if (createdId) {
await goto(`/feeds/${createdId}/mapping`);
}
} catch (err) {
error = applyApiFormError(err, i18n.t("flash.feed.createFailed"));
} finally {
saving = false;
}
}
async function saveEdit(event: Event) {
event.preventDefault();
if (!editing) return;
saving = true;
clearFormFeedback();
try {
const nextType = isFileFeed(editing) ? "csv" : formType;
const body: Record<string, string | number> = {
name: formName.trim(),
feed_type: nextType,
sync_interval_minutes: parseSyncIntervalMinutes(formInterval)
};
if (!isFileFeed(editing)) {
body.url = formUrl.trim();
}
await api(`/api/feeds/${editing.id}`, {
method: "PATCH",
body
});
showEdit = false;
editing = null;
success = i18n.t("flash.feed.updated");
notifySuccess(i18n.t("toast.feed.updated"));
await load();
} catch (err) {
error = applyApiFormError(err, i18n.t("flash.feed.updateFailed"));
} finally {
saving = false;
}
}
async function syncFeed(feed: FeedRow) {
const id = String(feed.id);
if (syncingIds[id]) return;
// Lock before awaitable preflight so double-clicks cannot emit duplicate gate toasts.
syncingIds = { ...syncingIds, [id]: true };
if (isFtpFeed(feed)) {
notifyError(ftpSyncUnsupportedMessage());
const nextSyncing = { ...syncingIds };
delete nextSyncing[id];
syncingIds = nextSyncing;
return;
}
if (String(feed.status ?? "").toLowerCase() === "unmapped") {
mappingIncompleteIds = { ...mappingIncompleteIds, [id]: true };
notifyError(i18n.t("toast.feed.mapBeforeSync"), {
actions: [{ label: i18n.t("feeds.mapFields"), href: `/feeds/${id}/mapping` }]
});
const nextSyncing = { ...syncingIds };
delete nextSyncing[id];
syncingIds = nextSyncing;
return;
}
if (!isFeedActive(feed)) {
notifyError(i18n.t("toast.feed.activateBeforeSync"));
const nextSyncing = { ...syncingIds };
delete nextSyncing[id];
syncingIds = nextSyncing;
return;
}
const ready = await assertSyncMappingReady(feed);
if (!ready) {
const nextSyncing = { ...syncingIds };
delete nextSyncing[id];
syncingIds = nextSyncing;
return;
}
syncAborts[id]?.abort();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FEED_SYNC_CLIENT_TIMEOUT_MS);
syncAborts = { ...syncAborts, [id]: ac };
error = "";
success = "";
try {
let job = await api<SyncJob>(`/api/feeds/${id}/sync`, {
method: "POST",
signal: ac.signal
});
trackEvent("feed_synced", { result: "started" });
const jobId = String(job.id ?? "");
if (jobId && isActiveProcessingJob(job.status)) {
job = await pollFeedSyncJob(id, jobId, ac.signal);
}
const status = String(job.status ?? "completed").toLowerCase();
if (status === "failed" || status === "error") {
trackEvent("feed_synced", { result: "failed", error_code: "sync_failed" });
const detail = String(job.error ?? "").trim();
error = detail || i18n.t("feeds.syncFailed");
notifyError(error, { alert: "sync_fail" });
return;
}
const products = syncJobProductsLabel(job);
success = i18n.t("flash.feed.syncFinishedNamed", { name: feed.name ?? id, status, products });
notifySuccess(i18n.t("toast.feed.syncFinished"), {
alert: "sync_done",
description: `"${feed.name ?? id}" — ${products}. Process a sample when ready.`,
actions: [
{ label: i18n.t("feeds.viewProducts"), href: `/products?feed_id=${id}` },
{ label: i18n.t("feeds.processing"), href: "/processing" }
],
duration: 12000
});
await load();
} catch (err) {
if (isAbortError(err)) {
trackEvent("feed_synced", { result: "failed", error_code: "timeout" });
error = i18n.t("feeds.syncTimeout");
notifyError(error, { alert: "sync_fail" });
} else {
const message = failureMessage(err, i18n.t("feeds.syncFailed"));
const mappingGate =
/map required fields before syncing|map at least one source field|map fields before/i.test(
message
);
if (mappingGate) {
trackEvent("feed_synced", { result: "failed", error_code: "mapping_gate" });
mappingIncompleteIds = { ...mappingIncompleteIds, [id]: true };
// Client preflight is the primary path; API gate is toast-only (no page Alert).
notifyError(message, {
alert: "sync_fail",
actions: [{ label: i18n.t("feeds.mapFields"), href: `/feeds/${id}/mapping` }]
});
} else {
trackEvent("feed_synced", { result: "failed", error_code: "sync_failed" });
error = notifyApiError(err, i18n.t("toast.feed.syncFailed"), { alert: "sync_fail" });
}
}
} finally {
clearTimeout(timer);
const nextSyncing = { ...syncingIds };
delete nextSyncing[id];
syncingIds = nextSyncing;
const nextAborts = { ...syncAborts };
if (nextAborts[id] === ac) delete nextAborts[id];
syncAborts = nextAborts;
}
}
async function toggleActive(feed: FeedRow) {
// status=mapped is eligible to sync but is not "active"; Activate must turn mapped → active.
const nextStatus = isFeedSyncing(feed) ? "inactive" : "active";
error = "";
success = "";
try {
await api(`/api/feeds/${feed.id}`, {
method: "PATCH",
body: { status: nextStatus }
});
success =
nextStatus === "active" ? i18n.t("feeds.activated") : i18n.t("feeds.deactivated");
notifySuccess(success);
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.feed.statusFailed"));
}
}
async function deleteFeed(feed: FeedRow) {
if (!confirm(i18n.t("confirm.deleteFeed", { name: feed.name ?? feed.id }))) return;
error = "";
success = "";
try {
await api(`/api/feeds/${feed.id}`, { method: "DELETE" });
success = i18n.t("flash.feed.deleted");
notifySuccess(i18n.t("toast.feed.deleted"));
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.feed.deleteFailed"));
}
}
async function openHistory(feed: FeedRow) {
historyAbort?.abort();
const ac = new AbortController();
historyAbort = ac;
historyFeed = feed;
historyJobs = [];
showHistory = true;
historyLoading = true;
error = "";
try {
const payload = await api<{ jobs?: SyncJob[] }>(`/api/feeds/${feed.id}/sync-jobs`, {
signal: ac.signal
});
if (ac.signal.aborted) return;
historyJobs = Array.isArray(payload.jobs)
? payload.jobs
: unwrapList(payload as ListResponse<SyncJob>);
} catch (err) {
if (isAbortError(err) || ac.signal.aborted) return;
error = failureMessage(err, i18n.t("feeds.historyLoadFailed"));
showHistory = false;
} finally {
if (!ac.signal.aborted) historyLoading = false;
}
}
function statusTone(feed: FeedRow): string {
const incomplete = Boolean(mappingIncompleteIds[String(feed.id)]);
const s = String(feed.status ?? "").toLowerCase();
if (incomplete && s === "mapped") {
return "border-chart-amber/50 bg-chart-amber/15 text-foreground";
}
if (s === "unmapped" || s === "inactive") {
return "border-chart-amber/50 bg-chart-amber/15 text-foreground";
}
if (s === "active" || s === "mapped") {
return "border-chart-green/40 bg-card-green text-foreground";
}
if (s === "error" || s === "failed") {
return "border-destructive/40 bg-card-red text-foreground";
}
return "border-border bg-muted text-foreground";
}
</script>
<PageShell
title={i18n.t("feeds.title")}
description={loading && feeds.length === 0
? i18n.t("feeds.loadingDesc")
: i18n.t("feeds.desc", { page: filtered.length, total })}
>
{#snippet actions()}
<Button variant="outline" class="w-full sm:w-auto" onclick={() => void goto("/stores")}>{i18n.t("feeds.storesHub")}</Button>
<Button class="w-full sm:w-auto" onclick={() => openAdd()} data-tour="feeds-add" data-assistant-target="feeds-add">
<Plus class="h-4 w-4" />
{i18n.t("feeds.addFeed")}
</Button>
{/snippet}
<Alert id={FEEDS_PAGE_ERROR_ID} message={error} />
<Alert tone="success" message={success} />
{#if activationFocus}
<div
class="flex flex-col gap-3 rounded-lg border border-border bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
data-tour="feeds-activation-focus"
role="status"
>
<p class="text-sm text-foreground">
{activationFocus === "map"
? i18n.t("activation.focus.map")
: i18n.t("activation.focus.sync")}
</p>
<div class="flex flex-wrap gap-2">
{#if feeds.length === 0}
<Button size="sm" onclick={() => openAdd()}>{i18n.t("feeds.connectFeed")}</Button>
{/if}
<Button size="sm" variant="outline" onclick={() => (activationFocus = null)}>
{i18n.t("activation.focus.dismiss")}
</Button>
</div>
</div>
{/if}
{#if feeds.some((f) => isFtpFeed(f))}
<FtpMigrateNotice
id="feeds-ftp-migrate"
onAddHttps={() => openAdd("url")}
onAddCsv={() => openAdd("file")}
/>
{/if}
<div class="grid grid-cols-1 gap-4">
{#if loading && feeds.length === 0}
<StatCardsSkeleton count={4} />
{:else if feeds.length > 0 || appliedSearch.trim()}
<FeedStats
total={stats.total}
active={stats.active}
mapped={stats.mapped}
products={stats.products}
processed={stats.processed}
unprocessed={stats.unprocessed}
/>
{/if}
</div>
{#if feeds.length > 0 || appliedSearch.trim() || loading}
<Card>
<div class="border-b border-border p-4">
<div class="flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center">
<div class="relative min-w-0 w-full flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={i18n.t("feeds.searchPlaceholder")}
class="w-full pl-8"
bind:value={searchQuery}
oninput={() => onSearchInput(searchQuery)}
/>
</div>
<Button variant="outline" size="sm" class="shrink-0 self-end sm:self-auto" loading={loading && feeds.length > 0} disabled={loading} onclick={() => void load()}>
<RefreshCw class="h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
</div>
</div>
</Card>
{/if}
<Card class="min-w-0 overflow-hidden">
{#if loading && feeds.length === 0}
<ListSkeleton rows={6} />
{:else if feeds.length === 0 && !appliedSearch.trim()}
<div class="p-6">
<EmptyState
title={i18n.t("empty.feeds.noneTitle")}
message={i18n.t("empty.feeds.noneMessage")}
>
<Button onclick={() => openAdd()} data-tour="feeds-empty-add" data-assistant-target="feeds-add">
<Plus class="mr-2 h-4 w-4" />
{i18n.t("feeds.connectFeed")}
</Button>
<Button variant="outline" onclick={() => openAdd("file")}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("feeds.uploadCsvCta")}
</Button>
<Button variant="outline" onclick={() => void goto("/stores")}>
{i18n.t("feeds.storesHub")}
</Button>
</EmptyState>
</div>
{:else if feeds.length === 0}
<div class="p-6">
<EmptyState title={i18n.t("empty.feeds.noMatchTitle")} message={i18n.t("empty.feeds.noMatchMessage")}>
<Button
variant="outline"
onclick={() => {
searchQuery = "";
appliedSearch = "";
listPage = 1;
void load();
}}
>
{i18n.t("common.clearSearch")}
</Button>
<Button onclick={() => openAdd()}>
<Plus class="mr-2 h-4 w-4" />
{i18n.t("feeds.connectFeed")}
</Button>
</EmptyState>
</div>
{:else}
<div class="max-h-[min(70vh,640px)] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="min-w-0">
<button
type="button"
class="flex items-center font-semibold hover:opacity-80"
onclick={() => handleSort("name")}
>
{i18n.t("common.nameCol")}
<NameSortIcon class="ml-2 h-4 w-4 text-muted-foreground" />
</button>
</TableHead>
<TableHead class="hidden max-w-[14rem] md:table-cell">{i18n.t("feeds.col.source")}</TableHead>
<TableHead class="hidden whitespace-nowrap sm:table-cell">
<button
type="button"
class="flex items-center font-semibold hover:opacity-80"
onclick={() => handleSort("mapping")}
>
{i18n.t("feeds.col.mapping")}
<MappingSortIcon class="ml-2 h-4 w-4 text-muted-foreground" />
</button>
</TableHead>
<TableHead class="hidden whitespace-nowrap md:table-cell">
<button
type="button"
class="flex items-center font-semibold hover:opacity-80"
onclick={() => handleSort("products")}
>
{i18n.t("common.products")}
<ProductsSortIcon class="ml-2 h-4 w-4 text-muted-foreground" />
</button>
</TableHead>
<TableHead class="hidden whitespace-nowrap lg:table-cell">{i18n.t("common.status")}</TableHead>
<TableHead class="hidden whitespace-nowrap lg:table-cell">
<button
type="button"
class="flex items-center font-semibold hover:opacity-80"
onclick={() => handleSort("lastSynced")}
>
{i18n.t("feeds.col.lastData")}
<SyncedSortIcon class="ml-2 h-4 w-4 text-muted-foreground" />
</button>
</TableHead>
<TableHead stickyRight>{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filtered as feed (feed.id)}
{@const id = String(feed.id)}
{@const source = feedSourceLabel(feed)}
{@const fileBased = isFileFeed(feed)}
{@const ftpUnsupported = isFtpFeed(feed)}
{@const mapped = feedHasMappings(feed)}
{@const mappingCount = feedMappingFieldCount(feed)}
{@const productCount = feedProductCount(feed)}
{@const lastData = feedLastDataAt(feed)}
{@const liveSynced = Boolean(
typeof feed.last_synced_at === "string" && feed.last_synced_at.trim()
)}
<TableRow>
<TableCell class="max-w-[12rem] font-medium">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate">{feed.name ?? id}</span>
{#if ftpUnsupported}
<Badge
variant="outline"
class="shrink-0 border-chart-amber/50 bg-chart-amber/15 text-xs"
title={ftpSyncUnsupportedMessage()}
>
{i18n.t("feeds.ftpBadge")}
</Badge>
{/if}
{#if feed.feed_type}
<Badge variant="outline" class="shrink-0 text-xs uppercase">
{feed.feed_type}
</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden max-w-[14rem] md:table-cell">
{#if source}
<div class="flex min-w-0 items-center gap-2">
{#if fileBased}
<Upload class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate text-sm" title={source}>{source}</span>
{:else}
<LinkIcon class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate text-sm" title={source}>{shortenUrl(source)}</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0"
onclick={() => void copyUrl(source, id)}
aria-label={i18n.t("feeds.copyUrl")}
>
{#if copiedUrl === id}
<Check class="h-3 w-3 text-success" />
{:else}
<Copy class="h-3 w-3" />
{/if}
</Button>
{/if}
</div>
{:else}
<span class="text-sm text-muted-foreground"></span>
{/if}
</TableCell>
<TableCell class="hidden whitespace-nowrap sm:table-cell">
{#if mapped}
<Badge
variant="outline"
class={mappingTone(feed)}
title={mappingCount > 0
? mappingCount === 1
? i18n.t("feeds.fieldsMappedTitle", { count: mappingCount })
: i18n.t("feeds.fieldsMappedTitlePlural", { count: mappingCount })
: i18n.t("feeds.mappingsSaved")}
>
{mappingCount > 0
? i18n.t("feeds.fieldsMapped", { count: mappingCount })
: i18n.t("feeds.mapped")}
</Badge>
{:else}
<Badge
variant="outline"
class={mappingTone(feed)}
title={i18n.t("feeds.notMappedTitle")}
>
{i18n.t("feeds.notMapped")}
</Badge>
{/if}
</TableCell>
<TableCell class="hidden whitespace-nowrap md:table-cell">
<span
class="tabular-nums"
title={productCount > 0
? i18n.t("feeds.productsFromFeed", {
count: productCount.toLocaleString()
})
: i18n.t("feeds.noProductsYet")}
>
{productCount.toLocaleString()}
</span>
</TableCell>
<TableCell class="hidden whitespace-nowrap lg:table-cell">
<Badge variant="outline" class={statusTone(feed)}>
{feedStatusLabel(feed, {
mappingIncomplete: Boolean(mappingIncompleteIds[id])
})}
</Badge>
</TableCell>
<TableCell class="hidden whitespace-nowrap lg:table-cell">
{#if syncingIds[id]}
<div class="flex items-center gap-2 text-chart-amber">
<span
class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
<div>
<div class="font-medium">{i18n.t("feeds.syncing")}</div>
<div class="text-xs text-muted-foreground">
{i18n.t("feeds.syncingHint")}
</div>
</div>
</div>
{:else if lastData}
<div title={lastDataTooltip(feed)}>
<div>{formatRelativeTime(lastData)}</div>
<div class="text-xs text-muted-foreground">
{liveSynced ? i18n.t("feeds.liveSync") : i18n.t("feeds.productData")}
· {formatDate(lastData)}
</div>
</div>
{:else}
<span class="text-muted-foreground" title={lastDataTooltip(feed)}>{i18n.t("common.never")}</span>
{/if}
</TableCell>
<TableCell stickyRight>
<div class="flex items-center justify-end gap-1">
<Button
variant="outline"
size="sm"
class="min-h-11 min-w-11 touch-manipulation px-2 sm:min-h-9 sm:min-w-0 sm:px-3{activationFocus === 'map'
? ' ring-2 ring-ring'
: ''}"
data-tour="feed-open-mapping"
data-assistant-target="feed-open-mapping"
aria-label={i18n.t("feeds.mapFields")}
disabled={!!syncingIds[id]}
onclick={() => void goto(`/feeds/${id}/mapping`)}
>
{i18n.t("feeds.map")}
</Button>
<Button
variant="outline"
size="sm"
class="hidden min-h-11 touch-manipulation sm:inline-flex sm:min-h-9{activationFocus === 'sync'
? ' ring-2 ring-ring'
: ''}"
data-tour="feed-sync-now"
data-assistant-target="feed-sync-now"
aria-label={ftpUnsupported
? ftpSyncUnsupportedShortMessage()
: syncingIds[id]
? i18n.t("feeds.syncInProgress")
: i18n.t("feeds.syncNow")}
title={ftpUnsupported
? ftpSyncUnsupportedMessage()
: syncingIds[id]
? undefined
: i18n.t("feeds.syncManualTitle")}
loading={!!syncingIds[id]}
disabled={!canSyncFeed(feed) && !syncingIds[id]}
onclick={() => void syncFeed(feed)}
>
{syncingIds[id] ? i18n.t("feeds.syncingShort") : i18n.t("feeds.sync")}
</Button>
<FeedActionsMenu
{feed}
syncing={!!syncingIds[id]}
onEditMapping={() => void goto(`/feeds/${id}/mapping`)}
onEditFeed={() => openEdit(feed)}
onSync={() => void syncFeed(feed)}
onViewHistory={() => void openHistory(feed)}
onToggleActive={() => void toggleActive(feed)}
onDelete={canAdmin ? () => void deleteFeed(feed) : undefined}
/>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<ProductPagination
currentPage={listPage}
{totalPages}
onPageChange={changePage}
productsLength={feeds.length}
total={total}
pageSize={DEFAULT_PAGE_SIZE}
/>
{/if}
</Card>
</PageShell>
<Dialog
bind:open={showAdd}
title={i18n.t("feeds.addTitle")}
description={i18n.t("feeds.addDesc")}
class="!max-w-5xl"
>
<form
id="add-feed-form"
class="space-y-4"
onsubmit={createFeed}
aria-describedby={error ? FEEDS_FORM_ERROR_ID : undefined}
>
{#if error}
<Alert id={FEEDS_FORM_ERROR_ID} message={error} />
{/if}
<div class="space-y-1.5">
<Label for="add-feed-name">{i18n.t("feeds.field.name")}</Label>
<Input
id="add-feed-name"
bind:value={formName}
required
placeholder={i18n.t("feeds.field.namePlaceholder")}
aria-invalid={fieldInvalid(fieldErrors, "name")}
aria-describedby={fieldDescribedBy(fieldErrors, "name", FEEDS_FORM_ERROR_ID)}
/>
</div>
<Tabs bind:value={formSource} onValueChange={onFormSourceChange}>
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="url">{i18n.t("feeds.tab.url")}</TabsTrigger>
<TabsTrigger value="file">{i18n.t("feeds.tab.file")}</TabsTrigger>
</TabsList>
<TabsContent value="url" class="mt-4 space-y-4">
<div class="space-y-1.5">
<Label for="add-feed-url">{i18n.t("feeds.field.url")}</Label>
<Input
id="add-feed-url"
type="url"
bind:value={formUrl}
required={formSource === "url"}
placeholder={i18n.t("feeds.field.urlPlaceholder")}
aria-invalid={fieldInvalid(fieldErrors, "url")}
aria-describedby={fieldDescribedBy(fieldErrors, "url", FEEDS_FORM_ERROR_ID)}
/>
</div>
<div class="space-y-1.5">
<Label for="add-feed-type">{i18n.t("feeds.field.type")}</Label>
<Select id="add-feed-type" bind:value={formType}>
<option value="xml">XML</option>
<option value="csv">CSV</option>
</Select>
</div>
<FeedFormatHelp format={formType === "csv" ? "csv" : "xml"} />
</TabsContent>
<TabsContent value="file" class="mt-4 space-y-4">
<div class="space-y-1.5">
<Label for="add-feed-file">{i18n.t("feeds.field.file")}</Label>
<input
id="add-feed-file"
bind:this={formFileInput}
type="file"
accept=".csv,text/csv"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:mr-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium"
onchange={onCsvSelected}
required={formSource === "file"}
aria-invalid={fieldInvalid(fieldErrors, "file")}
aria-describedby={fieldDescribedBy(fieldErrors, "file", FEEDS_FORM_ERROR_ID)}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("feeds.fileHint")}
</p>
{#if formFile}
<p class="text-sm text-foreground">{formFile.name}</p>
{/if}
</div>
<FeedFormatHelp format="csv" />
</TabsContent>
</Tabs>
<div class="space-y-1.5">
<Label for="add-feed-interval">{i18n.t("feeds.field.interval")}</Label>
<Input
id="add-feed-interval"
type="number"
min="5"
bind:value={formInterval}
aria-describedby="add-feed-interval-hint"
/>
<p id="add-feed-interval-hint" class="text-xs text-muted-foreground">
{i18n.t("feeds.field.intervalHint")}
</p>
</div>
</form>
{#snippet footer()}
<Button type="button" variant="outline" onclick={() => (showAdd = false)}>{i18n.t("common.cancel")}</Button>
<Button type="submit" form="add-feed-form" loading={saving}>{i18n.t("feeds.createFeed")}</Button>
{/snippet}
</Dialog>
<Dialog bind:open={showEdit} title={i18n.t("feeds.editTitle")} description={i18n.t("feeds.editDesc")}>
<form
id="edit-feed-form"
class="space-y-4"
onsubmit={saveEdit}
aria-describedby={error ? FEEDS_FORM_ERROR_ID : undefined}
>
{#if error}
<Alert id={FEEDS_FORM_ERROR_ID} message={error} />
{/if}
<div class="space-y-1.5">
<Label for="edit-feed-name">{i18n.t("feeds.field.name")}</Label>
<Input
id="edit-feed-name"
bind:value={formName}
required
aria-invalid={fieldInvalid(fieldErrors, "name")}
aria-describedby={fieldDescribedBy(fieldErrors, "name", FEEDS_FORM_ERROR_ID)}
/>
</div>
{#if editing && isFileFeed(editing)}
<div class="space-y-1.5">
<Label>{i18n.t("feeds.csvSource")}</Label>
<p class="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{feedSourceLabel(editing) || i18n.t("feeds.uploadedCsv")}
</p>
<p class="text-xs text-muted-foreground">
{i18n.t("feeds.replaceFileHint")}
</p>
</div>
{:else}
<div class="space-y-1.5">
<Label for="edit-feed-url">{i18n.t("feeds.field.url")}</Label>
<Input
id="edit-feed-url"
type="url"
bind:value={formUrl}
aria-invalid={fieldInvalid(fieldErrors, "url")}
aria-describedby={fieldDescribedBy(fieldErrors, "url", FEEDS_FORM_ERROR_ID)}
/>
</div>
{/if}
<div class="space-y-1.5">
<Label for="edit-feed-type">{i18n.t("feeds.field.type")}</Label>
<Select id="edit-feed-type" bind:value={formType} disabled={editing != null && isFileFeed(editing)}>
<option value="xml">XML</option>
<option value="csv">CSV</option>
</Select>
{#if editing && isFileFeed(editing)}
<p class="text-xs text-muted-foreground">{i18n.t("feeds.csvTypeLocked")}</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="edit-feed-interval">{i18n.t("feeds.field.interval")}</Label>
<Input
id="edit-feed-interval"
type="number"
min="5"
bind:value={formInterval}
aria-describedby="edit-feed-interval-hint"
/>
<p id="edit-feed-interval-hint" class="text-xs text-muted-foreground">
{i18n.t("feeds.field.intervalHint")}
</p>
</div>
</form>
{#snippet footer()}
<Button type="button" variant="outline" onclick={() => (showEdit = false)}>{i18n.t("common.cancel")}</Button>
<Button type="submit" form="edit-feed-form" loading={saving}>{i18n.t("feeds.saveChanges")}</Button>
{/snippet}
</Dialog>
<Dialog
bind:open={showHistory}
title={i18n.t("feeds.historyTitle")}
description={historyFeed
? [
i18n.t("feeds.historyDesc", { name: String(historyFeed.name ?? historyFeed.id) }),
historyFeed.last_synced_at
? i18n.t("feeds.historyLastSynced", {
relative: formatRelativeTime(historyFeed.last_synced_at),
absolute: formatDateTime(historyFeed.last_synced_at)
})
: i18n.t("feeds.historyNeverSynced")
].join(" ")
: ""}
class="max-w-3xl"
>
{#if historyLoading}
<div class="py-8"><Spinner label={i18n.t("feeds.historyLoading")} /></div>
{:else if historyJobs.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">{i18n.t("feeds.historyEmpty")}</p>
{:else}
<div class="max-h-[420px] overflow-auto rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("common.status")}</TableHead>
<TableHead>{i18n.t("common.products")}</TableHead>
<TableHead>{i18n.t("feeds.history.started")}</TableHead>
<TableHead>{i18n.t("feeds.history.completed")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each historyJobs as job (job.id)}
<TableRow>
<TableCell>
<Badge variant="outline" class={`capitalize ${syncJobStatusTone(job.status)}`}>
{job.status ?? "—"}
</Badge>
{#if job.error}
<p class="mt-1 max-w-xs truncate text-xs text-destructive" title={String(job.error)}>
{job.error}
</p>
{/if}
</TableCell>
<TableCell class="text-sm">{syncJobProductsLabel(job)}</TableCell>
<TableCell class="text-muted-foreground">
<div>{formatRelativeTime(job.started_at ?? job.created_at)}</div>
<div class="text-xs">{formatDateTime(job.started_at ?? job.created_at)}</div>
</TableCell>
<TableCell class="text-muted-foreground">
{#if job.completed_at}
<div>{formatRelativeTime(job.completed_at)}</div>
<div class="text-xs">{formatDateTime(job.completed_at)}</div>
{:else}
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
{/if}
{#snippet footer()}
<Button variant="outline" onclick={() => (showHistory = false)}>{i18n.t("common.close")}</Button>
{/snippet}
</Dialog>