Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,882 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user