Files
descrybe/apps/web/src/routes/stores/+page.svelte
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

390 lines
12 KiB
Svelte

<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import {
ArrowRight,
FileUp,
Key,
Link as LinkIcon,
Share2,
ShoppingBag,
Store
} from "@lucide/svelte";
import { api } from "$lib/api";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import type { MeResponse, ShopifyConfig, WooCommerceConfig } from "$lib/types";
import {
needsStoreReconnect,
STORE_RECONNECT_TARGETS,
storeConnectorAction,
type StoreReconnectTarget
} from "$lib/store-reconnect";
import PageShell from "$lib/components/PageShell.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import StoreReconnectBanner from "$lib/components/stores/StoreReconnectBanner.svelte";
import StoreSyncDeliveryBanner from "$lib/components/stores/StoreSyncDeliveryBanner.svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { upgradeMessageForFeature } from "$lib/plan-upgrade-message";
import PlanUpgradePanel from "$lib/components/PlanUpgradePanel.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "$lib/components/ui";
type ConnectorCard = {
id: string;
title: string;
description: string;
href: string;
cta: string;
icon: typeof Store;
status: "ready" | "export" | "reconnect" | "disconnected";
statusLabel: string;
};
let canAdmin = $state(false);
let reconnectItems = $state<StoreReconnectTarget[]>([]);
let wooNeedsReconnect = $state(false);
let shopifyNeedsReconnect = $state(false);
let wooConnected = $state(false);
let shopifyConnected = $state(false);
const canHub = $derived(planCapabilities.can("stores.hub"));
const canWoo = $derived(planCapabilities.can("stores.woocommerce"));
const canShopify = $derived(planCapabilities.can("stores.shopify"));
const storesGate = $derived(
upgradeMessageForFeature("stores.hub", authSession.isCompanyAdmin, {
sectionEnabled: planCapabilities.sectionEnabled("stores")
})
);
const connectors = $derived.by((): ConnectorCard[] => {
const wooAction = storeConnectorAction({
canAdmin,
connected: wooConnected,
needsReconnect: wooNeedsReconnect
});
const shopifyAction = storeConnectorAction({
canAdmin,
connected: shopifyConnected,
needsReconnect: shopifyNeedsReconnect
});
return [
{
id: "woocommerce",
title: i18n.t("stores.woo.title"),
description: i18n.t("stores.woo.description"),
href: wooNeedsReconnect
? STORE_RECONNECT_TARGETS.find((t) => t.id === "woocommerce")!.href
: "/woocommerce",
cta:
wooAction === "reconnect"
? i18n.t("stores.woo.ctaReconnect")
: wooAction === "open"
? i18n.t("stores.woo.ctaOpen")
: wooAction === "view"
? i18n.t("stores.woo.ctaView")
: i18n.t("stores.woo.ctaConnect"),
icon: Store,
status: wooNeedsReconnect ? "reconnect" : wooConnected ? "ready" : "disconnected",
statusLabel: wooNeedsReconnect
? i18n.t("stores.status.needsReconnect")
: wooConnected
? i18n.t("stores.status.connected")
: i18n.t("stores.status.notConnected")
},
{
id: "shopify",
title: i18n.t("stores.shopify.title"),
description: i18n.t("stores.shopify.description"),
href: shopifyNeedsReconnect
? STORE_RECONNECT_TARGETS.find((t) => t.id === "shopify")!.href
: "/stores/shopify",
cta:
shopifyAction === "reconnect"
? i18n.t("stores.shopify.ctaReconnect")
: shopifyAction === "open"
? i18n.t("stores.shopify.ctaOpen")
: shopifyAction === "view"
? i18n.t("stores.shopify.ctaView")
: i18n.t("stores.shopify.ctaConnect"),
icon: ShoppingBag,
status: shopifyNeedsReconnect ? "reconnect" : shopifyConnected ? "ready" : "disconnected",
statusLabel: shopifyNeedsReconnect
? i18n.t("stores.status.needsReconnect")
: shopifyConnected
? i18n.t("stores.status.connected")
: i18n.t("stores.status.notConnected")
},
{
id: "feed-url",
title: i18n.t("stores.feedUrl.title"),
description: i18n.t("stores.feedUrl.description"),
href: "/feeds?add=1&source=url",
cta: i18n.t("stores.feedUrl.cta"),
icon: LinkIcon,
status: "ready",
statusLabel: i18n.t("stores.status.ready")
},
{
id: "csv-upload",
title: i18n.t("stores.csv.title"),
description: i18n.t("stores.csv.description"),
href: "/feeds?add=1&source=file",
cta: i18n.t("stores.csv.cta"),
icon: FileUp,
status: "ready",
statusLabel: i18n.t("stores.status.ready")
},
{
id: "export",
title: i18n.t("stores.export.title"),
description: i18n.t("stores.export.description"),
href: "/export-feeds",
cta: i18n.t("stores.export.cta"),
icon: Share2,
status: "export",
statusLabel: i18n.t("stores.status.outbound")
},
{
id: "api-keys",
title: i18n.t("stores.apiKeys.title"),
description: canAdmin
? i18n.t("stores.apiKeys.description")
: i18n.t("stores.apiKeys.descriptionMember"),
href: "/settings?tab=api-keys",
cta: canAdmin ? i18n.t("stores.apiKeys.cta") : i18n.t("stores.apiKeys.ctaView"),
icon: Key,
status: "export",
statusLabel: i18n.t("stores.status.reissue")
}
];
});
function statusVariant(
status: ConnectorCard["status"]
): "success" | "secondary" | "outline" | "warning" {
if (status === "reconnect") return "warning";
if (status === "ready") return "success";
if (status === "disconnected") return "secondary";
if (status === "export") return "outline";
return "secondary";
}
onMount(() => {
const ac = new AbortController();
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me", { signal: ac.signal });
if (ac.signal.aborted) return;
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
if (ac.signal.aborted) return;
canAdmin = authSession.isCompanyAdmin;
}
const [woo, shopify] = await Promise.all([
api<WooCommerceConfig>("/api/woocommerce", { signal: ac.signal }).catch(() => null),
api<ShopifyConfig>("/api/shopify", { signal: ac.signal }).catch(() => null)
]);
if (ac.signal.aborted) return;
wooNeedsReconnect = needsStoreReconnect(woo);
shopifyNeedsReconnect = needsStoreReconnect(shopify);
wooConnected = Boolean(woo?.has_credentials) && !wooNeedsReconnect;
shopifyConnected = Boolean(shopify?.has_credentials) && !shopifyNeedsReconnect;
const items: StoreReconnectTarget[] = [];
if (wooNeedsReconnect) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "woocommerce");
if (t) items.push(t);
}
if (shopifyNeedsReconnect) {
const t = STORE_RECONNECT_TARGETS.find((x) => x.id === "shopify");
if (t) items.push(t);
}
reconnectItems = items;
})();
return () => ac.abort();
});
</script>
<PageShell
title={i18n.t("stores.title")}
description={i18n.t("stores.description")}
>
{#snippet actions()}
<Button variant="outline" onclick={() => void goto("/feeds")} data-tour="stores-all-feeds">
{i18n.t("stores.allInputFeeds")}
</Button>
<Button onclick={() => void goto("/export-feeds")}>{i18n.t("stores.exportFeeds")}</Button>
{/snippet}
{#if reconnectItems.length > 0}
<div class="mb-4">
<StoreReconnectBanner {canAdmin} items={reconnectItems} />
</div>
{:else}
<p class="text-sm text-muted-foreground" id="store-hub-migration-note">
{i18n.t("stores.migrationNote")}
</p>
{/if}
<div class="mb-2">
<StoreSyncDeliveryBanner compact={true} />
</div>
<p class="text-sm text-muted-foreground" id="store-hub-intro">
{i18n.t("stores.docsPrefix")}
<a class="font-medium text-foreground underline-offset-4 hover:underline" href="/docs"
>{i18n.t("stores.docsApiOverview")}</a
>
{i18n.t("stores.docsSuffix")}
</p>
{#if !canHub}
<div data-testid="stores-plan-upgrade">
<PlanUpgradePanel
title={storesGate.title}
message={storesGate.message}
cta={storesGate.cta}
stillWorks={storesGate.stillWorks}
featureKey={storesGate.featureKey}
compact={true}
/>
</div>
{/if}
{#if !wooConnected && !shopifyConnected && reconnectItems.length === 0}
<div class="mb-2">
<EmptyState
title={canAdmin ? i18n.t("empty.stores.noneTitle") : i18n.t("empty.stores.noneTitleMember")}
message={canAdmin ? i18n.t("empty.stores.noneMessage") : i18n.t("empty.stores.noneMessageMember")}
>
{#if canAdmin && canHub}
<Button onclick={() => void goto("/stores/wizard")} data-tour="stores-empty-guided">
{i18n.t("stores.wizard.guidedSetup")}
</Button>
{/if}
{#if canAdmin}
<Button variant="outline" onclick={() => void goto("/woocommerce")} data-tour="stores-empty-woo">
{i18n.t("stores.woo.ctaConnect")}
</Button>
{:else}
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-empty-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
{/if}
</EmptyState>
</div>
{/if}
<section
class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
aria-labelledby="store-hub-heading"
data-tour="store-hub"
data-assistant-target="store-hub"
>
<h2 id="store-hub-heading" class="sr-only">{i18n.t("stores.connectorsHeading")}</h2>
{#each connectors as card}
{@const Icon = card.icon}
<article
class="flex flex-col"
aria-labelledby={`store-card-title-${card.id}`}
data-tour="store-card-{card.id}"
>
<Card class="flex h-full flex-col">
<CardHeader class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-foreground"
aria-hidden="true"
>
<Icon class="h-5 w-5" />
</div>
<Badge variant={statusVariant(card.status)} aria-label={i18n.t("stores.statusAria", { label: card.statusLabel })}>
{card.statusLabel}
</Badge>
</div>
<div class="space-y-1">
<CardTitle id={`store-card-title-${card.id}`} class="text-lg">{card.title}</CardTitle>
<CardDescription id={`store-card-desc-${card.id}`}>{card.description}</CardDescription>
</div>
</CardHeader>
<CardContent class="mt-auto pt-0">
{@const channelLocked =
(card.id === "woocommerce" && !canWoo) ||
(card.id === "shopify" && !canShopify)}
{#if channelLocked}
<Badge variant="outline" class="mb-2 w-fit">{i18n.t("stores.connectorLocked")}</Badge>
<Button
class="w-full justify-between"
variant="outline"
onclick={() => void goto(storesGate.cta.primaryHref)}
data-tour="store-connect-{card.id}-locked"
aria-label={i18n.t("stores.connectorUpgrade")}
>
{i18n.t("stores.connectorUpgrade")}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
{:else}
<Button
class="w-full justify-between"
variant="default"
onclick={() => void goto(card.href)}
data-tour="store-connect-{card.id}"
data-assistant-target="store-connect-{card.id}"
aria-describedby={`store-card-desc-${card.id}`}
aria-label={i18n.t("stores.ctaAria", { cta: card.cta, title: card.title })}
>
{card.cta}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
{/if}
</CardContent>
</Card>
</article>
{/each}
</section>
<aside class="mt-2" aria-labelledby="store-hub-start-title">
<Card class="border-dashed">
<CardHeader>
<CardTitle id="store-hub-start-title" class="text-base">{i18n.t("stores.startTitle")}</CardTitle>
<CardDescription>
{canAdmin ? i18n.t("stores.startBody") : i18n.t("stores.startBodyMember")}
</CardDescription>
</CardHeader>
{#if canAdmin && canHub}
<CardContent class="pt-0">
<Button
variant="outline"
class="w-full justify-between sm:w-auto"
onclick={() => void goto("/stores/wizard")}
data-tour="stores-guided-setup"
aria-label={i18n.t("stores.wizard.guidedSetupAria")}
>
{i18n.t("stores.wizard.guidedSetup")}
<ArrowRight class="h-4 w-4" aria-hidden="true" />
</Button>
</CardContent>
{:else if !canAdmin}
<CardContent class="pt-0">
<a href="/settings?tab=team">
<Button type="button" variant="outline" data-tour="stores-ask-admin">
{i18n.t("common.askAdmin")}
</Button>
</a>
</CardContent>
{/if}
</Card>
</aside>
</PageShell>