Files
descrybe/apps/web/src/routes/dashboard/+page.svelte
T

883 lines
29 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList, unwrapTotal, isAbortError, RECENT_JOBS_LIMIT } from "$lib/list";
import type {
ListResponse,
MeResponse,
ProcessingJob,
ShopifyConfig,
WooCommerceConfig
} from "$lib/types";
import Alert from "$lib/components/Alert.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ListSkeleton from "$lib/components/ListSkeleton.svelte";
import StatCardsSkeleton from "$lib/components/StatCardsSkeleton.svelte";
import DashboardStats from "$lib/components/DashboardStats.svelte";
import NewsFeed from "$lib/components/NewsFeed.svelte";
import UpgradeBanner from "$lib/components/UpgradeBanner.svelte";
import ActivationChecklist from "$lib/components/ActivationChecklist.svelte";
import MigratedEtlGapsPanel from "$lib/components/MigratedEtlGapsPanel.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { isA1NamedPlan } from "$lib/etl-gaps";
import { isA1PaygPlanPure } from "$lib/plan-cohort";
import {
needsStoreReconnect,
STORE_RECONNECT_TARGETS,
type StoreReconnectTarget
} from "$lib/store-reconnect";
import {
Badge,
Button,
Card,
CardContent,
Skeleton,
type BadgeVariant
} from "$lib/components/ui";
import { formatJobStatusLabelPending, formatProcessingTypeLabel } from "$lib/job-status";
import {
billingRecovery,
formatCreditsStatusLabel,
formatCreditsStatusLine,
hasActivePlan,
isEnterprisePlan,
isFreePlan,
isPayAsYouGoPlan,
planDisplayName,
remainingCreditsOf,
upgradeCtaForRole,
withUpgradeHint,
type PlanLike
} from "$lib/billing-display";
import { canManageCompany, isCompanyAdmin } from "$lib/company-admin";
import { formatRelativeTime } from "$lib/utils";
import {
Play,
GraduationCap,
Package,
Plus,
Rss,
ArrowRight,
Upload,
Share2,
ChevronRight,
Map as MapIcon,
RefreshCw
} from "@lucide/svelte";
import { tutorial } from "$lib/tutorial";
import { i18n } from "$lib/i18n";
type FeedsOverview = ListResponse<unknown> & {
total?: number;
product_total?: number;
processed_total?: number;
unprocessed_total?: number;
};
let me = $state<MeResponse | null>(null);
let productTotal = $state(0);
let processedTotal = $state(0);
let unprocessedTotal = $state(0);
let categoryTotal = $state(0);
let attributeTotal = $state(0);
let feedTotal = $state(0);
let processingCount = $state(0);
let recentJobs = $state<ProcessingJob[]>([]);
let error = $state("");
let loading = $state(true);
let dashboardAbort: AbortController | null = null;
let storeReconnectItems = $state<StoreReconnectTarget[]>([]);
function isActiveJobStatus(status: string | null | undefined): boolean {
const s = (status || "").toLowerCase();
return s === "running" || s === "pending" || s === "processing" || s === "queued";
}
function jobLabel(job: ProcessingJob): string {
const type = job.processing_type ?? job.type;
if (type) return formatProcessingTypeLabel(type);
return i18n.t("dashboard.jobFallback", { id: job.id });
}
function statusVariant(status: string | null | undefined): BadgeVariant {
switch ((status ?? "").toLowerCase()) {
case "completed":
case "success":
case "done":
return "success";
case "failed":
case "error":
return "destructive";
case "cancelled":
case "canceled":
case "skipped":
return "secondary";
case "running":
case "processing":
return "secondary";
case "pending":
case "queued":
return "outline";
default:
return "default";
}
}
function statusLabel(status: string | null | undefined): string {
return formatJobStatusLabelPending(status);
}
function jobWhen(job: ProcessingJob): string {
return formatRelativeTime(job.completed_at ?? job.started_at ?? job.created_at);
}
function isPlatformDemoCompany(res: MeResponse | null): boolean {
const name = (res?.company?.name ?? "").trim();
return /^platform demo$/i.test(name) || /^demo$/i.test(name);
}
function numField(payload: unknown, key: string): number | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const v = (payload as Record<string, unknown>)[key];
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
onMount(() => {
const ac = new AbortController();
dashboardAbort = ac;
void (async () => {
try {
const [meRes, feedsRes, catsRes, attrsRes, jobsRes, wooRes, shopifyRes] =
await Promise.all([
api<MeResponse>("/api/auth/me", { signal: ac.signal }),
// Feeds list includes company-scoped product_total / processed / unprocessed
// (same source as Products page tabs — not processed-only).
api<FeedsOverview>("/api/feeds?limit=1&offset=0", { signal: ac.signal }).catch(
(err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}
),
api<ListResponse<unknown> & { total?: number }>(
"/api/categories?limit=1&offset=0",
{ signal: ac.signal }
).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<ListResponse<unknown> & { total?: number }>(
"/api/attributes?limit=1&offset=0",
{ signal: ac.signal }
).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<ListResponse<ProcessingJob>>(`/api/processing/jobs?limit=${RECENT_JOBS_LIMIT}`, {
signal: ac.signal
}).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}),
api<WooCommerceConfig>("/api/woocommerce", { signal: ac.signal }).catch(
(err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
}
),
api<ShopifyConfig>("/api/shopify", { signal: ac.signal }).catch((err) => {
if (isAbortError(err) || ac.signal.aborted) throw err;
return null;
})
]);
if (ac.signal.aborted) return;
me = meRes;
if (feedsRes) {
feedTotal = unwrapTotal(feedsRes) ?? unwrapList(feedsRes).length;
productTotal =
numField(feedsRes, "product_total") ??
numField(meRes.credits, "product_count") ??
0;
processedTotal = numField(feedsRes, "processed_total") ?? 0;
unprocessedTotal = numField(feedsRes, "unprocessed_total") ?? 0;
} else {
productTotal = numField(meRes.credits, "product_count") ?? 0;
}
if (catsRes) {
categoryTotal = unwrapTotal(catsRes) ?? unwrapList(catsRes).length;
}
if (attrsRes) {
attributeTotal = unwrapTotal(attrsRes) ?? unwrapList(attrsRes).length;
}
if (jobsRes) {
const jobs = unwrapList(jobsRes);
recentJobs = jobs.slice(0, 5);
processingCount = jobs.filter((j) => isActiveJobStatus(j.status)).length;
}
const items: StoreReconnectTarget[] = [];
if (needsStoreReconnect(wooRes)) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "woocommerce");
if (t) items.push(t);
}
if (needsStoreReconnect(shopifyRes)) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "shopify");
if (t) items.push(t);
}
storeReconnectItems = items;
} catch (err) {
if (isAbortError(err) || ac.signal.aborted) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("dashboard.loadFailed"));
} finally {
if (!ac.signal.aborted) loading = false;
}
})();
return () => {
ac.abort();
if (dashboardAbort === ac) dashboardAbort = null;
};
});
const usedCredits = $derived.by(() => me?.credits?.used_credits ?? 0);
const remainingCredits = $derived.by(() => remainingCreditsOf(me?.credits) ?? 0);
const planInfo = $derived.by((): PlanLike | null => {
const plan = me?.credits?.plan;
if (!plan || typeof plan !== "object") return null;
return plan as PlanLike;
});
const planAssigned = $derived(hasActivePlan(me?.credits, planInfo));
const planName = $derived(planDisplayName(planInfo, me?.credits));
const enterprise = $derived(planAssigned && isEnterprisePlan(planInfo));
const isTrial = $derived(Boolean(planInfo?.is_trial));
const isFreePlanFlag = $derived(isFreePlan(planInfo, me?.credits));
const isPayg = $derived(isPayAsYouGoPlan(planInfo, me?.credits));
const remainingLabel = $derived(
formatCreditsStatusLabel(remainingCredits, planInfo, me?.credits)
);
const statusLine = $derived(
formatCreditsStatusLine(remainingCredits, planInfo, me?.credits)
);
const canManageBilling = $derived(isCompanyAdmin(me));
const canManageWorkspace = $derived(canManageCompany(me));
const upgradeCta = $derived(upgradeCtaForRole(canManageBilling));
const recovery = $derived(
billingRecovery({
credits: me?.credits,
plan: planInfo,
canManageBilling
})
);
const outOfCredits = $derived(!isFreePlanFlag && !enterprise && remainingCredits <= 0);
const lowCredits = $derived(
Boolean(me?.credits?.low_credits) &&
!outOfCredits &&
!isFreePlanFlag &&
!enterprise &&
!isPayg
);
const atProductLimit = $derived(Boolean(me?.credits?.at_product_limit) && !enterprise);
const productCount = $derived(me?.credits?.product_count ?? productTotal);
const maxProducts = $derived(
typeof me?.credits?.max_products === "number"
? me.credits.max_products
: typeof planInfo?.max_products === "number"
? planInfo.max_products
: planInfo?.max_products === null
? null
: null
);
const showUpgradeBanner = $derived(
Boolean(recovery) ||
isFreePlanFlag ||
outOfCredits ||
atProductLimit ||
isTrial ||
lowCredits
);
/** Empty only when this company truly has no catalog/feeds. */
const isCatalogEmpty = $derived(
productTotal === 0 && categoryTotal === 0 && attributeTotal === 0 && feedTotal === 0
);
const isDemoSandbox = $derived(isPlatformDemoCompany(me));
const companyLabel = $derived(
(me?.company?.name ?? i18n.t("dashboard.workspaceFallback")).trim() ||
i18n.t("dashboard.workspaceFallback")
);
const trialEndLabel = $derived.by(() => {
const raw = planInfo?.next_billing_date;
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
});
const subtitle = $derived.by(() => {
const bits: string[] = [];
if (statusLine) bits.push(statusLine);
if (processingCount > 0) {
bits.push(
i18n.t(
processingCount === 1 ? "dashboard.jobsRunning" : "dashboard.jobsRunningPlural",
{ count: processingCount.toLocaleString() }
)
);
}
return bits.join(" · ");
});
const canMonitorJobs = $derived(planCapabilities.can("processing.monitor"));
const canExportFeeds = $derived(planCapabilities.can("feeds.export_feeds"));
const planFlags = $derived({
name: typeof planInfo?.name === "string" ? planInfo.name : "",
is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined,
is_custom: typeof planInfo?.is_custom === "boolean" ? planInfo.is_custom : undefined
});
/** A1 cohort never sees cutover reconnect / ETL honesty on the merchant shell. */
const isA1Cohort = $derived(isA1PaygPlanPure(planFlags) || isA1NamedPlan(planFlags));
const showStoreReconnect = $derived(
!isA1Cohort &&
planCapabilities.can("dashboard.store_reconnect") &&
storeReconnectItems.length > 0
);
const processHref = $derived(
unprocessedTotal > 0 ? "/products?type=raw&status=unprocessed" : "/products"
);
const workflowSteps = $derived([
{
id: "feeds",
href: feedTotal > 0 ? "/feeds" : "/feeds?add=1",
label: i18n.t("nav.feeds"),
detail:
feedTotal > 0
? i18n.t(
feedTotal === 1
? "dashboard.workflow.feeds.sources"
: "dashboard.workflow.feeds.sourcesPlural",
{ count: feedTotal.toLocaleString() }
)
: i18n.t("dashboard.workflow.feeds.connect"),
icon: Rss,
done: feedTotal > 0,
tour: "workflow-feeds"
},
{
id: "map",
href: "/feeds?focus=map",
label: i18n.t("dashboard.workflow.map"),
detail:
feedTotal > 0
? i18n.t("dashboard.workflow.map.ready")
: i18n.t("dashboard.workflow.map.detail"),
icon: MapIcon,
done: feedTotal > 0 && productTotal > 0,
tour: "workflow-map"
},
{
id: "sync",
href: "/feeds?focus=sync",
label: i18n.t("dashboard.workflow.sync"),
detail:
productTotal > 0
? i18n.t("dashboard.workflow.products.count", {
count: productTotal.toLocaleString()
})
: feedTotal > 0
? i18n.t("dashboard.workflow.sync.ready")
: i18n.t("dashboard.workflow.sync.detail"),
icon: RefreshCw,
done: productTotal > 0,
tour: "workflow-sync"
},
{
id: "process",
href: processHref,
label: i18n.t("dashboard.workflow.process"),
detail:
processingCount > 0
? i18n.t("dashboard.workflow.processActive", {
count: processingCount.toLocaleString()
})
: unprocessedTotal > 0
? i18n.t("dashboard.workflow.processWaiting", {
count: unprocessedTotal.toLocaleString()
})
: processedTotal > 0
? i18n.t("dashboard.workflow.processDone", {
count: processedTotal.toLocaleString()
})
: i18n.t("dashboard.workflow.processRun"),
icon: Play,
done: processedTotal > 0,
tour: "workflow-process"
}
]);
</script>
{#if loading}
<div class="mx-auto max-w-7xl space-y-8" aria-busy="true">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex max-w-xl flex-col gap-2">
<Skeleton class="h-8 w-56" />
<Skeleton class="h-4 w-full max-w-md" />
</div>
<div class="flex flex-wrap gap-2">
<Skeleton class="h-9 w-32" />
<Skeleton class="h-9 w-36" />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each [1, 2, 3, 4] as i (i)}
<Skeleton class="h-24 w-full rounded-xl" />
{/each}
</div>
<StatCardsSkeleton count={5} />
<div class="rounded-xl border border-border bg-surface">
<ListSkeleton rows={3} />
</div>
</div>
{:else if error}
<Alert message={error} />
{:else}
<div class="mx-auto max-w-7xl space-y-8">
<header
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
data-tour="dashboard-welcome"
data-assistant-target="dashboard-welcome"
>
<div class="flex max-w-2xl flex-col gap-2">
{#if planAssigned}
<p class="text-xs font-medium uppercase tracking-wide text-text-muted">
{planName}{isTrial ? ` · ${i18n.t("dashboard.trialBadge")}` : ""}
</p>
{/if}
<h1 class="text-2xl font-bold tracking-tight text-text sm:text-3xl">
{#if isCatalogEmpty}
{isDemoSandbox
? i18n.t("dashboard.demoSandbox")
: i18n.t("dashboard.welcomeTo", { name: companyLabel })}
{:else}
{companyLabel}
{/if}
</h1>
<p class="text-sm text-text-muted">
{#if isCatalogEmpty && isDemoSandbox}
{i18n.t("dashboard.demoEmptyHint")}
{:else if isCatalogEmpty}
{#if isPayg}
{i18n.t("dashboard.emptyPaygHint")}
{:else}
{i18n.t("dashboard.emptyCreditsHint", { credits: remainingLabel })}
{/if}
{:else}
{subtitle}
{/if}
</p>
</div>
<div class="flex flex-wrap gap-2" role="group" aria-label={i18n.t("dashboard.actions")}>
{#if tutorial.canResume && !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.resume()}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.resumeTutorial")}
</Button>
{:else if tutorial.canRestart && !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.restart()}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.restartTutorial")}
</Button>
{:else if !tutorial.active}
<Button
variant="outline"
size="sm"
data-tour="start-tutorial"
onclick={() => void tutorial.start(true)}
>
<GraduationCap class="mr-2 h-4 w-4" />
{i18n.t("dashboard.startTutorial")}
</Button>
{/if}
{#if isCatalogEmpty && isDemoSandbox}
<Button size="sm" onclick={() => goto("/feeds?add=1")}>
<Rss class="mr-2 h-4 w-4" />
{i18n.t("dashboard.connectFeed")}
</Button>
<Button size="sm" variant="outline" onclick={() => goto("/files")}>
<Upload class="mr-2 h-4 w-4" />
{i18n.t("dashboard.uploadCsv")}
</Button>
{:else if !isCatalogEmpty && unprocessedTotal > 0}
<Button size="sm" onclick={() => goto(processHref)}>
<Play class="mr-2 h-4 w-4" />
{i18n.t("dashboard.processProducts")}
</Button>
{:else if !isCatalogEmpty}
<Button size="sm" onclick={() => goto("/products")}>
<Package class="mr-2 h-4 w-4" />
{i18n.t("dashboard.openProducts")}
</Button>
{/if}
</div>
</header>
{#if showStoreReconnect}
<StoreReconnectBanner canAdmin={canManageBilling} items={storeReconnectItems} />
{/if}
<MigratedEtlGapsPanel
canAdmin={canManageWorkspace}
planName={typeof planInfo?.name === "string" ? planInfo.name : null}
isLegacy={typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : null}
isCustom={typeof planInfo?.is_custom === "boolean" ? planInfo.is_custom : null}
/>
{#if showUpgradeBanner}
{#if recovery}
<UpgradeBanner
tone={recovery.tone}
title={recovery.title}
message={recovery.message}
primaryHref={recovery.primaryHref}
primaryLabel={recovery.kind === "past_due" && recovery.openPortal
? i18n.t("dashboard.goToBilling")
: recovery.primaryLabel}
showSales={recovery.showSales}
/>
{:else if isFreePlanFlag}
<UpgradeBanner
tone="info"
title={i18n.t("dashboard.freePlanTitle")}
message={withUpgradeHint(
maxProducts
? i18n.t("dashboard.freePlanMessageWithLimit", {
used: productCount.toLocaleString(),
max: maxProducts.toLocaleString()
})
: i18n.t("dashboard.freePlanMessage"),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if outOfCredits}
<UpgradeBanner
tone="danger"
title={i18n.t("dashboard.outOfCreditsTitle")}
message={withUpgradeHint(i18n.t("dashboard.outOfCreditsMessage"), upgradeCta)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if atProductLimit}
<UpgradeBanner
tone="danger"
title={i18n.t("dashboard.productLimitTitle")}
message={withUpgradeHint(
maxProducts
? i18n.t("dashboard.productLimitMessage", {
plan: planName,
max: maxProducts.toLocaleString(),
count: productCount.toLocaleString()
})
: i18n.t("dashboard.productLimitMessageFull", { plan: planName }),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.comparePlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if isTrial}
<UpgradeBanner
tone="info"
title={i18n.t("dashboard.trialTitle", { plan: planName })}
message={withUpgradeHint(
trialEndLabel
? i18n.t("dashboard.trialMessageDated", {
credits: remainingLabel,
date: trialEndLabel
})
: i18n.t("dashboard.trialMessage", { credits: remainingLabel }),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.viewPlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{:else if lowCredits}
<UpgradeBanner
tone="warning"
title={i18n.t("dashboard.lowCreditsTitle")}
message={withUpgradeHint(
i18n.t("dashboard.lowCreditsMessage", {
credits: remainingLabel,
plan: planName
}),
upgradeCta
)}
primaryHref={upgradeCta.primaryHref}
primaryLabel={canManageBilling
? i18n.t("dashboard.comparePlans")
: upgradeCta.primaryLabel}
showSales={upgradeCta.showSales}
/>
{/if}
{/if}
{#if isCatalogEmpty && !isDemoSandbox}
<ActivationChecklist />
{:else if isCatalogEmpty && isDemoSandbox}
<EmptyState
title={i18n.t("dashboard.demoEmptyTitle")}
message={i18n.t("dashboard.demoEmptyMessage")}
>
<Button variant="outline" onclick={() => goto("/feeds?add=1")}>
<Plus class="mr-2 h-4 w-4" />
{i18n.t("dashboard.connectFeedAnyway")}
</Button>
</EmptyState>
{/if}
<section class="space-y-3" aria-labelledby="dashboard-workflow-heading">
<div>
<h2 id="dashboard-workflow-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.workflowTitle")}
</h2>
<p class="text-xs text-text-muted">
{i18n.t("dashboard.workflowHint", { name: companyLabel })}
</p>
</div>
<ol
class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
data-tour="dashboard-workflow"
>
{#each workflowSteps as step, index (step.id)}
<li class="relative">
<a
href={step.href}
class="group flex h-full items-start gap-3 rounded-xl border border-border bg-surface p-4 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour={step.tour}
>
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg {step.done
? 'bg-primary text-primary-foreground'
: 'bg-muted text-text'}"
aria-hidden="true"
>
<step.icon class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5 text-sm font-semibold text-text">
<span class="text-text-muted">{index + 1}.</span>
{step.label}
</span>
<span class="mt-0.5 block text-xs text-text-muted">{step.detail}</span>
</span>
<ChevronRight
class="mt-1 h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5 group-hover:text-text"
aria-hidden="true"
/>
</a>
</li>
{/each}
</ol>
</section>
{#if !isCatalogEmpty}
<section class="space-y-3" aria-labelledby="dashboard-overview-heading">
<div class="flex items-end justify-between gap-3">
<div>
<h2 id="dashboard-overview-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.overviewTitle")}
</h2>
<p class="text-xs text-text-muted">
{i18n.t("dashboard.overviewHint", { name: companyLabel })}
</p>
</div>
</div>
<DashboardStats
stats={{
products: productTotal,
categories: categoryTotal,
attributes: attributeTotal,
feeds: feedTotal,
processed: processedTotal,
unprocessed: unprocessedTotal
}}
credits={{ usedCredits, remainingCredits }}
plan={planInfo}
/>
</section>
{/if}
<div class="grid gap-6 lg:grid-cols-5">
{#if !isCatalogEmpty}
<section class="space-y-3 lg:col-span-3" aria-labelledby="dashboard-jobs-heading">
<div class="flex items-end justify-between gap-4">
<div>
<h2 id="dashboard-jobs-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.recentActivity")}
</h2>
<p class="text-xs text-text-muted">
{#if processingCount > 0}
{i18n.t(
processingCount === 1
? "dashboard.activeJobs"
: "dashboard.activeJobsPlural",
{ count: processingCount.toLocaleString() }
)}
{:else}
{i18n.t("dashboard.latestJobs")}
{/if}
</p>
</div>
{#if canMonitorJobs}
<a
href="/processing"
class="text-sm font-medium text-link underline-offset-4 hover:underline"
>
{i18n.t("common.viewAll")}
</a>
{/if}
</div>
<Card class="border-border bg-surface shadow-sm">
<CardContent class="pt-4">
{#if recentJobs.length === 0}
<EmptyState
title={i18n.t("empty.dashboard.noJobsTitle")}
message={i18n.t("empty.dashboard.noJobsMessage")}
>
<a href="/products?type=raw&status=unprocessed">
<Button size="sm">
<Play class="mr-2 h-4 w-4" />
{i18n.t("dashboard.startJob")}
</Button>
</a>
</EmptyState>
{:else}
<ul class="divide-y divide-border">
{#each recentJobs as job (job.id)}
<li class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-text">{jobLabel(job)}</p>
<p class="text-xs text-text-muted">{jobWhen(job)}</p>
</div>
<Badge variant={statusVariant(job.status)}>{statusLabel(job.status)}</Badge>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
</section>
{/if}
<section
class="min-w-0 space-y-3 {isCatalogEmpty ? 'lg:col-span-5' : 'lg:col-span-2'}"
aria-labelledby="dashboard-shortcuts-heading"
>
<div>
<h2 id="dashboard-shortcuts-heading" class="text-base font-semibold tracking-tight text-text">
{i18n.t("dashboard.quickLinks")}
</h2>
<p class="text-xs text-text-muted">{i18n.t("dashboard.quickLinksHint")}</p>
</div>
<div class="space-y-2">
<a
href="/feeds"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="manage-feeds"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Rss class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.feeds")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.feedsImportMap")}</p>
</div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a>
<a
href="/products"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="start-processing"
data-assistant-target="start-processing"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Package class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.products")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.productsBrowse")}</p>
</div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a>
{#if canMonitorJobs}
<a
href="/processing"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Play class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.jobs")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.jobsMonitor")}</p>
</div>
<ArrowRight
class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5"
/>
</a>
{/if}
{#if canExportFeeds}
<a
href="/export-feeds"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-tour="manage-exports"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Share2 class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text">{i18n.t("nav.exports")}</p>
<p class="text-xs text-text-muted">{i18n.t("dashboard.exportsTemplates")}</p>
</div>
<ArrowRight
class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5"
/>
</a>
{/if}
</div>
<div class="min-w-0 pb-16 pt-2">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-muted">
{i18n.t("dashboard.whatsNew")}
</h3>
<NewsFeed limit={1} />
</div>
</section>
</div>
</div>
{/if}