166 lines
4.7 KiB
Svelte
166 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
import { formatCredits } from "$lib/utils";
|
|
import {
|
|
formatCreditsRemaining,
|
|
isEnterprisePlan,
|
|
isPayAsYouGoPlan,
|
|
type PlanLike
|
|
} from "$lib/billing-display";
|
|
import { i18n } from "$lib/i18n";
|
|
import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte";
|
|
import { planCapabilities } from "$lib/plan-capabilities.svelte";
|
|
import { featureKeyForHref } from "$lib/plan-capabilities";
|
|
|
|
let {
|
|
stats,
|
|
credits,
|
|
plan = null
|
|
}: {
|
|
stats: {
|
|
products: number;
|
|
categories: number;
|
|
attributes: number;
|
|
feeds: number;
|
|
processed?: number;
|
|
unprocessed?: number;
|
|
};
|
|
credits: { usedCredits: string | number; remainingCredits: string | number };
|
|
plan?: PlanLike | null;
|
|
} = $props();
|
|
|
|
const remainingNum = $derived(
|
|
typeof credits.remainingCredits === "number"
|
|
? credits.remainingCredits
|
|
: Number(credits.remainingCredits)
|
|
);
|
|
|
|
const remainingLabel = $derived(formatCreditsRemaining(remainingNum, plan));
|
|
const isPayg = $derived(isPayAsYouGoPlan(plan));
|
|
const isEnterprise = $derived(isEnterprisePlan(plan));
|
|
|
|
const productsHint = $derived.by(() => {
|
|
const processed = stats.processed;
|
|
const unprocessed = stats.unprocessed;
|
|
if (
|
|
typeof processed === "number" &&
|
|
typeof unprocessed === "number" &&
|
|
(processed > 0 || unprocessed > 0)
|
|
) {
|
|
return i18n.t("stats.productsHint", {
|
|
processed: processed.toLocaleString(),
|
|
unprocessed: unprocessed.toLocaleString()
|
|
});
|
|
}
|
|
return i18n.t("stats.openCatalog");
|
|
});
|
|
|
|
const cards = $derived([
|
|
{
|
|
href: "/products",
|
|
label: i18n.t("stats.products"),
|
|
value: stats.products,
|
|
hint: productsHint,
|
|
iconWrap: "bg-card-blue text-text",
|
|
icon: Package,
|
|
tour: "stats-products"
|
|
},
|
|
{
|
|
href: "/categories",
|
|
label: i18n.t("stats.categories"),
|
|
value: stats.categories,
|
|
hint: i18n.t("stats.catalogTree"),
|
|
iconWrap: "bg-muted text-text",
|
|
icon: FolderTree,
|
|
tour: "stats-categories"
|
|
},
|
|
{
|
|
href: "/attributes",
|
|
label: i18n.t("stats.attributes"),
|
|
value: stats.attributes,
|
|
hint: i18n.t("stats.attributeLibrary"),
|
|
iconWrap: "bg-muted text-text",
|
|
icon: ListTree,
|
|
tour: "stats-attributes"
|
|
},
|
|
{
|
|
href: "/feeds",
|
|
label: i18n.t("stats.feeds"),
|
|
value: stats.feeds,
|
|
hint: i18n.t("stats.importSources"),
|
|
iconWrap: "bg-muted text-text",
|
|
icon: Rss,
|
|
tour: "stats-feeds"
|
|
}
|
|
// Every tile is a link, so hide the ones this member cannot open — a plan gate
|
|
// or the owner's per-member overlay (settings > team) can deny any of them.
|
|
].filter((card) => {
|
|
const key = featureKeyForHref(card.href);
|
|
return !key || planCapabilities.can(key);
|
|
}));
|
|
</script>
|
|
|
|
<div
|
|
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
|
|
data-tour="dashboard-stats"
|
|
>
|
|
{#each cards as card (card.tour)}
|
|
<a
|
|
href={card.href}
|
|
class="group rounded-xl border border-border bg-surface 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={card.tour}
|
|
>
|
|
<div class="flex items-start justify-between gap-2 p-4 pb-1">
|
|
<div class="min-w-0">
|
|
<p class="text-xs font-medium text-text-muted">{card.label}</p>
|
|
<p class="mt-1 text-2xl font-semibold tracking-tight text-text">
|
|
{card.value.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {card.iconWrap}"
|
|
aria-hidden="true"
|
|
>
|
|
<card.icon class="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
<div class="px-4 pb-3 text-xs text-text-muted group-hover:text-text/80">
|
|
{card.hint}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
|
|
<a
|
|
href="/billing"
|
|
class="group rounded-xl border border-border bg-surface 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="stats-credits"
|
|
>
|
|
<div class="flex items-start justify-between gap-2 p-4 pb-1">
|
|
<div class="min-w-0">
|
|
<p class="text-xs font-medium text-text-muted">{i18n.t("stats.credits")}</p>
|
|
<p class="mt-1 text-2xl font-semibold tracking-tight text-text">
|
|
{#if isPayg}
|
|
{i18n.t("stats.payAsYouGo")}
|
|
{:else}
|
|
{remainingLabel}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<div
|
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-success/15 text-success"
|
|
aria-hidden="true"
|
|
>
|
|
<Sparkles class="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
<div class="px-4 pb-3 text-xs text-text-muted group-hover:text-text/80">
|
|
{#if isPayg}
|
|
{i18n.t("stats.walletBilling")}
|
|
{:else if isEnterprise}
|
|
{i18n.t("stats.enterpriseBilling")}
|
|
{:else}
|
|
{i18n.t("stats.usedBilling", { used: formatCredits(credits.usedCredits) })}
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
</div>
|