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