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