Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
1425 lines
46 KiB
Svelte
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>
|