@voyantjs/distribution-ui 0.30.7 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/components/booking-link-detail-page.d.ts +10 -0
- package/dist/components/booking-link-detail-page.d.ts.map +1 -0
- package/dist/components/booking-link-detail-page.js +51 -0
- package/dist/components/channel-detail-page.d.ts +12 -0
- package/dist/components/channel-detail-page.d.ts.map +1 -0
- package/dist/components/channel-detail-page.js +41 -0
- package/dist/components/channel-sync-page.d.ts +8 -0
- package/dist/components/channel-sync-page.d.ts.map +1 -0
- package/dist/components/channel-sync-page.js +242 -0
- package/dist/components/channels-page.d.ts +6 -0
- package/dist/components/channels-page.d.ts.map +1 -0
- package/dist/components/channels-page.js +132 -0
- package/dist/components/commission-rule-detail-page.d.ts +10 -0
- package/dist/components/commission-rule-detail-page.d.ts.map +1 -0
- package/dist/components/commission-rule-detail-page.js +57 -0
- package/dist/components/contract-detail-page.d.ts +10 -0
- package/dist/components/contract-detail-page.d.ts.map +1 -0
- package/dist/components/contract-detail-page.js +64 -0
- package/dist/components/distribution-page.d.ts +26 -0
- package/dist/components/distribution-page.d.ts.map +1 -0
- package/dist/components/distribution-page.js +190 -0
- package/dist/components/distribution-tabs-primary.d.ts +57 -0
- package/dist/components/distribution-tabs-primary.d.ts.map +1 -0
- package/dist/components/distribution-tabs-primary.js +89 -0
- package/dist/components/distribution-tabs-secondary.d.ts +58 -0
- package/dist/components/distribution-tabs-secondary.d.ts.map +1 -0
- package/dist/components/distribution-tabs-secondary.js +89 -0
- package/dist/components/mapping-detail-page.d.ts +10 -0
- package/dist/components/mapping-detail-page.d.ts.map +1 -0
- package/dist/components/mapping-detail-page.js +51 -0
- package/dist/components/webhook-event-detail-page.d.ts +9 -0
- package/dist/components/webhook-event-detail-page.d.ts.map +1 -0
- package/dist/components/webhook-event-detail-page.js +46 -0
- package/dist/i18n/en.d.ts +381 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +363 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/messages.d.ts +244 -0
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +762 -0
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +381 -0
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +363 -0
- package/dist/i18n/utils.d.ts +4 -0
- package/dist/i18n/utils.d.ts.map +1 -0
- package/dist/i18n/utils.js +8 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -26,6 +26,14 @@ import { distributionUiRo } from "@voyantjs/distribution-ui/i18n/ro"
|
|
|
26
26
|
English-only apps should import only `./i18n/en`. Bilingual apps can import
|
|
27
27
|
`./i18n/en` and `./i18n/ro`.
|
|
28
28
|
|
|
29
|
+
## Components
|
|
30
|
+
|
|
31
|
+
- `DistributionPage`, `DistributionOverview`
|
|
32
|
+
- `ChannelsPage`, `ChannelSyncPage`
|
|
33
|
+
- `ChannelDetailPage`, `ContractDetailPage`, `CommissionRuleDetailPage`, `MappingDetailPage`, `BookingLinkDetailPage`, `WebhookEventDetailPage`
|
|
34
|
+
- `DistributionChannelsTab`, `DistributionContractsTab`, `DistributionCommissionsTab`
|
|
35
|
+
- `DistributionMappingsTab`, `DistributionBookingLinksTab`, `DistributionWebhooksTab`
|
|
36
|
+
|
|
29
37
|
## Not included (registry-only)
|
|
30
38
|
|
|
31
|
-
Some components couple to TanStack Router or template-local helpers and remain available only via the shadcn registry: `
|
|
39
|
+
Some components couple to TanStack Router or template-local helpers and remain available only via the shadcn registry: `distribution-dialogs-commercial`, `distribution-dialogs-commission`, `distribution-dialogs-sync`, `distribution-dialogs-webhook`. Import via `npx shadcn add @voyant/<component>` and customize per-project.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BookingLinkDetailPageProps {
|
|
2
|
+
id: string;
|
|
3
|
+
className?: string;
|
|
4
|
+
onBack?: () => void;
|
|
5
|
+
onDeleted?: () => void;
|
|
6
|
+
onChannelOpen?: (channelId: string) => void;
|
|
7
|
+
onBookingOpen?: (bookingId: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function BookingLinkDetailPage({ id, className, onBack, onDeleted, onChannelOpen, onBookingOpen, }: BookingLinkDetailPageProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
//# sourceMappingURL=booking-link-detail-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"booking-link-detail-page.d.ts","sourceRoot":"","sources":["../../src/components/booking-link-detail-page.tsx"],"names":[],"mappings":"AAyBA,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3C,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5C;AAID,wBAAgB,qBAAqB,CAAC,EACpC,EAAE,EACF,SAAS,EACT,MAAa,EACb,SAAgB,EAChB,aAAoB,EACpB,aAAoB,GACrB,EAAE,0BAA0B,2CAwI5B"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { distributionQueryKeys, fetchWithValidation, getBookingLinkQueryOptions, getBookingQueryOptions, getChannelQueryOptions, successEnvelope, useVoyantDistributionContext, } from "@voyantjs/distribution-react";
|
|
4
|
+
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmActionButton, } from "@voyantjs/ui/components";
|
|
5
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
6
|
+
import { ArrowLeft, Link2, Loader2, ReceiptText } from "lucide-react";
|
|
7
|
+
import { useDistributionUiI18nOrDefault } from "../i18n/index.js";
|
|
8
|
+
import { formatDistributionDateTime } from "./distribution-shared.js";
|
|
9
|
+
const noop = () => { };
|
|
10
|
+
export function BookingLinkDetailPage({ id, className, onBack = noop, onDeleted = noop, onChannelOpen = noop, onBookingOpen = noop, }) {
|
|
11
|
+
const i18n = useDistributionUiI18nOrDefault();
|
|
12
|
+
const { messages } = i18n;
|
|
13
|
+
const detail = messages.details.bookingLink;
|
|
14
|
+
const client = useVoyantDistributionContext();
|
|
15
|
+
const queryClient = useQueryClient();
|
|
16
|
+
const linkQuery = useQuery({
|
|
17
|
+
...getBookingLinkQueryOptions(client, id),
|
|
18
|
+
select: (result) => result.data,
|
|
19
|
+
});
|
|
20
|
+
const link = linkQuery.data;
|
|
21
|
+
const channelQuery = useQuery({
|
|
22
|
+
...getChannelQueryOptions(client, link?.channelId),
|
|
23
|
+
select: (result) => result.data,
|
|
24
|
+
enabled: Boolean(link?.channelId),
|
|
25
|
+
});
|
|
26
|
+
const bookingQuery = useQuery({
|
|
27
|
+
...getBookingQueryOptions(client, link?.bookingId),
|
|
28
|
+
select: (result) => result.data,
|
|
29
|
+
enabled: Boolean(link?.bookingId),
|
|
30
|
+
});
|
|
31
|
+
const remove = useMutation({
|
|
32
|
+
mutationFn: () => fetchWithValidation(`/v1/distribution/booking-links/${id}`, successEnvelope, client, {
|
|
33
|
+
method: "DELETE",
|
|
34
|
+
}),
|
|
35
|
+
onSuccess: () => {
|
|
36
|
+
void queryClient.invalidateQueries({ queryKey: distributionQueryKeys.bookingLinks() });
|
|
37
|
+
queryClient.removeQueries({ queryKey: distributionQueryKeys.bookingLink(id) });
|
|
38
|
+
onDeleted();
|
|
39
|
+
onBack();
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (linkQuery.isPending) {
|
|
43
|
+
return (_jsx("div", { className: "flex items-center justify-center py-12", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) }));
|
|
44
|
+
}
|
|
45
|
+
if (!link) {
|
|
46
|
+
return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-4 py-12", children: [_jsx("p", { className: "text-muted-foreground", children: detail.notFound }), _jsx(Button, { variant: "outline", onClick: onBack, children: messages.common.backToDistribution })] }));
|
|
47
|
+
}
|
|
48
|
+
return (_jsxs("div", { "data-slot": "booking-link-detail-page", className: cn("flex flex-col gap-6 p-6", className), children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: onBack, children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("div", { className: "flex-1", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: detail.title }), _jsxs("div", { className: "mt-1 flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: link.externalStatus ?? messages.common.unmappedStatus }), _jsx(Badge, { variant: "secondary", children: link.externalReference ?? messages.common.noReference })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: "outline", onClick: () => onChannelOpen(link.channelId), children: [_jsx(Link2, { className: "mr-2 h-4 w-4" }), detail.openChannel] }), _jsxs(Button, { variant: "outline", onClick: () => onBookingOpen(link.bookingId), children: [_jsx(ReceiptText, { className: "mr-2 h-4 w-4" }), detail.openBooking] }), _jsx(ConfirmActionButton, { buttonLabel: detail.deleteButton, confirmLabel: detail.deleteButton, title: detail.deleteConfirm, description: detail.deleteDescription, variant: "destructive", confirmVariant: "destructive", disabled: remove.isPending, onConfirm: async () => {
|
|
49
|
+
await remove.mutateAsync();
|
|
50
|
+
} })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: detail.sections.details }) }), _jsxs(CardContent, { className: "grid gap-3 text-sm md:grid-cols-2", children: [_jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.channelLabel, ":"] }), " ", _jsx("span", { children: channelQuery.data?.name ?? link.channelId })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.bookingLabel, ":"] }), " ", _jsx("span", { children: bookingQuery.data?.bookingNumber ?? link.bookingId })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.externalBooking, ":"] }), " ", _jsx("span", { children: link.externalBookingId ?? messages.common.none })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.reference, ":"] }), " ", _jsx("span", { children: link.externalReference ?? messages.common.none })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.bookedAtExternal, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(link.bookedAtExternal, i18n) })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.lastSynced, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(link.lastSyncedAt, i18n) })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.createdLabel, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(link.createdAt, i18n) })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.updatedLabel, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(link.updatedAt, i18n) })] })] })] })] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ChannelDetailPageProps {
|
|
2
|
+
id: string;
|
|
3
|
+
className?: string;
|
|
4
|
+
onBack?: () => void;
|
|
5
|
+
onDeleted?: () => void;
|
|
6
|
+
onContractOpen?: (contractId: string) => void;
|
|
7
|
+
onMappingOpen?: (mappingId: string) => void;
|
|
8
|
+
onBookingLinkOpen?: (bookingLinkId: string) => void;
|
|
9
|
+
onWebhookEventOpen?: (webhookEventId: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function ChannelDetailPage({ id, className, onBack, onDeleted, onContractOpen, onMappingOpen, onBookingLinkOpen, onWebhookEventOpen, }: ChannelDetailPageProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
//# sourceMappingURL=channel-detail-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel-detail-page.d.ts","sourceRoot":"","sources":["../../src/components/channel-detail-page.tsx"],"names":[],"mappings":"AAiCA,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7C,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3C,iBAAiB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,IAAI,CAAA;IACnD,kBAAkB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAA;CACtD;AAID,wBAAgB,iBAAiB,CAAC,EAChC,EAAE,EACF,SAAS,EACT,MAAa,EACb,SAAgB,EAChB,cAAqB,EACrB,aAAoB,EACpB,iBAAwB,EACxB,kBAAyB,GAC1B,EAAE,sBAAsB,2CAmRxB"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useBookingLinks, useBookings, useChannel, useChannelMutation, useContracts, useMappings, useProducts, useSuppliers, useWebhookEvents, } from "@voyantjs/distribution-react";
|
|
3
|
+
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmActionButton, } from "@voyantjs/ui/components";
|
|
4
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
5
|
+
import { ArrowLeft, Link2, Loader2, Package, Webhook } from "lucide-react";
|
|
6
|
+
import { useDistributionUiI18nOrDefault } from "../i18n/index.js";
|
|
7
|
+
import { formatDistributionDate, formatDistributionDateTime, getChannelKindLabel, getChannelStatusLabel, getContractStatusLabel, getPaymentOwnerLabel, } from "./distribution-shared.js";
|
|
8
|
+
const noop = () => { };
|
|
9
|
+
export function ChannelDetailPage({ id, className, onBack = noop, onDeleted = noop, onContractOpen = noop, onMappingOpen = noop, onBookingLinkOpen = noop, onWebhookEventOpen = noop, }) {
|
|
10
|
+
const i18n = useDistributionUiI18nOrDefault();
|
|
11
|
+
const { messages } = i18n;
|
|
12
|
+
const detail = messages.details.channel;
|
|
13
|
+
const channelQuery = useChannel(id);
|
|
14
|
+
const contractsQuery = useContracts({ channelId: id, limit: 25, offset: 0 });
|
|
15
|
+
const mappingsQuery = useMappings({ channelId: id, limit: 25, offset: 0 });
|
|
16
|
+
const bookingLinksQuery = useBookingLinks({ channelId: id, limit: 25, offset: 0 });
|
|
17
|
+
const webhookEventsQuery = useWebhookEvents({ channelId: id, limit: 25, offset: 0 });
|
|
18
|
+
const productsQuery = useProducts({ limit: 25, offset: 0 });
|
|
19
|
+
const bookingsQuery = useBookings({ limit: 25, offset: 0 });
|
|
20
|
+
const suppliersQuery = useSuppliers({ limit: 25, offset: 0 });
|
|
21
|
+
const { remove } = useChannelMutation();
|
|
22
|
+
if (channelQuery.isPending) {
|
|
23
|
+
return (_jsx("div", { className: "flex items-center justify-center py-12", children: _jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) }));
|
|
24
|
+
}
|
|
25
|
+
const channel = channelQuery.data;
|
|
26
|
+
if (!channel) {
|
|
27
|
+
return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-4 py-12", children: [_jsx("p", { className: "text-muted-foreground", children: detail.notFound }), _jsx(Button, { variant: "outline", onClick: onBack, children: messages.common.backToDistribution })] }));
|
|
28
|
+
}
|
|
29
|
+
const productsById = new Map((productsQuery.data?.data ?? []).map((product) => [product.id, product]));
|
|
30
|
+
const bookingsById = new Map((bookingsQuery.data?.data ?? []).map((booking) => [booking.id, booking]));
|
|
31
|
+
const suppliersById = new Map((suppliersQuery.data?.data ?? []).map((supplier) => [supplier.id, supplier]));
|
|
32
|
+
return (_jsxs("div", { "data-slot": "channel-detail-page", className: cn("flex flex-col gap-6 p-6", className), children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: onBack, children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsxs("div", { className: "flex-1", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: channel.name }), _jsxs("div", { className: "mt-1 flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: getChannelKindLabel(channel.kind, messages) }), _jsx(Badge, { variant: channel.status === "active" ? "default" : "secondary", children: getChannelStatusLabel(channel.status, messages) })] })] }), _jsx(ConfirmActionButton, { buttonLabel: detail.deleteButton, confirmLabel: detail.deleteButton, title: detail.deleteConfirm, description: detail.deleteDescription, variant: "destructive", confirmVariant: "destructive", disabled: remove.isPending, onConfirm: async () => {
|
|
33
|
+
await remove.mutateAsync(id);
|
|
34
|
+
onDeleted();
|
|
35
|
+
onBack();
|
|
36
|
+
} })] }), _jsxs("div", { className: "grid gap-6 md:grid-cols-2", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: detail.sections.details }) }), _jsxs(CardContent, { className: "grid gap-3 text-sm", children: [_jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.website, ":"] }), " ", _jsx("span", { children: channel.website ?? messages.common.none })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.contactName, ":"] }), " ", _jsx("span", { children: channel.contactName ?? messages.common.none })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [detail.labels.contactEmail, ":"] }), " ", _jsx("span", { children: channel.contactEmail ?? messages.common.none })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.createdLabel, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(channel.createdAt, i18n) })] }), _jsxs("div", { children: [_jsxs("span", { className: "text-muted-foreground", children: [messages.common.updatedLabel, ":"] }), " ", _jsx("span", { children: formatDistributionDateTime(channel.updatedAt, i18n) })] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: detail.sections.metadata }) }), _jsx(CardContent, { className: "text-sm", children: channel.metadata ? (_jsx("pre", { className: "overflow-x-auto rounded-md bg-muted p-3 text-xs", children: JSON.stringify(channel.metadata, null, 2) })) : (_jsx("p", { className: "text-muted-foreground", children: detail.empty.metadata })) })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center gap-2", children: [_jsx(Link2, { className: "h-4 w-4" }), _jsx(CardTitle, { children: detail.sections.contracts })] }), _jsx(CardContent, { className: "space-y-3 text-sm", children: (contractsQuery.data?.data.length ?? 0) === 0 ? (_jsx("p", { className: "text-muted-foreground", children: detail.empty.contracts })) : (contractsQuery.data?.data.map((contract) => (_jsxs("button", { type: "button", className: "block w-full rounded-md border p-3 text-left hover:bg-muted/40", onClick: () => onContractOpen(contract.id), children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: getContractStatusLabel(contract.status, messages) }), _jsxs("span", { children: [formatDistributionDate(contract.startsAt, i18n), " -", " ", contract.endsAt
|
|
37
|
+
? formatDistributionDate(contract.endsAt, i18n)
|
|
38
|
+
: messages.common.openEnded] })] }), _jsxs("div", { className: "mt-2 text-muted-foreground", children: [detail.labels.supplier, ":", " ", suppliersById.get(contract.supplierId ?? "")?.name ??
|
|
39
|
+
contract.supplierId ??
|
|
40
|
+
messages.common.none] }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.payment, ": ", getPaymentOwnerLabel(contract.paymentOwner, messages), " - ", detail.labels.cancellation, ":", " ", messages.common.cancellationOwnerLabels[contract.cancellationOwner]] }), contract.settlementTerms ? (_jsx("div", { className: "mt-2 whitespace-pre-wrap", children: contract.settlementTerms })) : null, contract.notes ? (_jsx("div", { className: "mt-2 whitespace-pre-wrap", children: contract.notes })) : null] }, contract.id)))) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center gap-2", children: [_jsx(Package, { className: "h-4 w-4" }), _jsx(CardTitle, { children: detail.sections.mappings })] }), _jsx(CardContent, { className: "space-y-3 text-sm", children: (mappingsQuery.data?.data.length ?? 0) === 0 ? (_jsx("p", { className: "text-muted-foreground", children: detail.empty.mappings })) : (mappingsQuery.data?.data.map((mapping) => (_jsxs("button", { type: "button", className: "block w-full rounded-md border p-3 text-left hover:bg-muted/40", onClick: () => onMappingOpen(mapping.id), children: [_jsx("div", { className: "font-medium", children: productsById.get(mapping.productId)?.name ?? mapping.productId }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.externalProduct, ": ", mapping.externalProductId] }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.rate, ": ", mapping.externalRateId ?? messages.common.none, " - ", detail.labels.category, ": ", mapping.externalCategoryId ?? messages.common.none] }), _jsx("div", { className: "mt-2", children: _jsx(Badge, { variant: mapping.active ? "default" : "secondary", children: mapping.active ? messages.common.active : messages.common.inactive }) })] }, mapping.id)))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: detail.sections.bookingLinks }) }), _jsx(CardContent, { className: "space-y-3 text-sm", children: (bookingLinksQuery.data?.data.length ?? 0) === 0 ? (_jsx("p", { className: "text-muted-foreground", children: detail.empty.bookingLinks })) : (bookingLinksQuery.data?.data.map((link) => (_jsxs("button", { type: "button", className: "block w-full rounded-md border p-3 text-left hover:bg-muted/40", onClick: () => onBookingLinkOpen(link.id), children: [_jsxs("div", { className: "font-medium", children: [detail.labels.booking, ":", " ", bookingsById.get(link.bookingId)?.bookingNumber ?? link.bookingId] }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.externalBooking, ": ", link.externalBookingId ?? messages.common.none] }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.reference, ": ", link.externalReference ?? messages.common.none] }), _jsxs("div", { className: "text-muted-foreground", children: [detail.labels.lastSynced, ": ", formatDistributionDateTime(link.lastSyncedAt, i18n)] })] }, link.id)))) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center gap-2", children: [_jsx(Webhook, { className: "h-4 w-4" }), _jsx(CardTitle, { children: detail.sections.webhooks })] }), _jsx(CardContent, { className: "space-y-3 text-sm", children: (webhookEventsQuery.data?.data.length ?? 0) === 0 ? (_jsx("p", { className: "text-muted-foreground", children: detail.empty.webhooks })) : (webhookEventsQuery.data?.data.map((event) => (_jsxs("button", { type: "button", className: "block w-full rounded-md border p-3 text-left hover:bg-muted/40", onClick: () => onWebhookEventOpen(event.id), children: [_jsx("div", { className: "font-medium", children: event.eventType }), _jsxs("div", { className: "text-muted-foreground", children: [messages.common.received, ": ", formatDistributionDateTime(event.receivedAt, i18n)] }), _jsx("div", { className: "mt-2", children: _jsx(Badge, { variant: "outline", children: messages.common.webhookStatusLabels[event.status] }) })] }, event.id)))) })] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type VoyantFetcher } from "@voyantjs/distribution-react";
|
|
2
|
+
export interface ChannelSyncPageProps {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
fetcher?: VoyantFetcher;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function ChannelSyncPage({ baseUrl, fetcher, className }?: ChannelSyncPageProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
//# sourceMappingURL=channel-sync-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel-sync-page.d.ts","sourceRoot":"","sources":["../../src/components/channel-sync-page.tsx"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAA;AA8HrC,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AA8DD,wBAAgB,eAAe,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,GAAE,oBAAyB,2CAiWzF"}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { defaultFetcher, useVoyantDistributionContext, } from "@voyantjs/distribution-react";
|
|
5
|
+
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Label, Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle, } from "@voyantjs/ui/components";
|
|
6
|
+
import { AsyncCombobox } from "@voyantjs/ui/components/async-combobox";
|
|
7
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@voyantjs/ui/components/empty";
|
|
8
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@voyantjs/ui/components/table";
|
|
9
|
+
import { AlertTriangle, ChevronDown, Loader2, RotateCw, X } from "lucide-react";
|
|
10
|
+
import { useEffect, useRef, useState } from "react";
|
|
11
|
+
// Fetch helpers
|
|
12
|
+
async function fetchJson(path, options, init) {
|
|
13
|
+
const res = await options.fetcher(joinUrl(options.baseUrl, path), {
|
|
14
|
+
...init,
|
|
15
|
+
headers: {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
...init?.headers,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
const body = text
|
|
22
|
+
? JSON.parse(text)
|
|
23
|
+
: {};
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(body.error ?? `Request failed: ${res.status}`);
|
|
26
|
+
}
|
|
27
|
+
return body;
|
|
28
|
+
}
|
|
29
|
+
const STATUS_VARIANTS = {
|
|
30
|
+
pending: "secondary",
|
|
31
|
+
ok: "default",
|
|
32
|
+
failed: "destructive",
|
|
33
|
+
compensated: "outline",
|
|
34
|
+
};
|
|
35
|
+
const STATUS_LABELS = {
|
|
36
|
+
pending: "Pending",
|
|
37
|
+
ok: "OK",
|
|
38
|
+
failed: "Failed",
|
|
39
|
+
compensated: "Compensated",
|
|
40
|
+
};
|
|
41
|
+
const STATUS_TILES = [
|
|
42
|
+
{ key: "pending", label: "Pending", description: "In flight", tone: "secondary" },
|
|
43
|
+
{ key: "ok", label: "Delivered", description: "Channel acknowledged", tone: "default" },
|
|
44
|
+
{ key: "failed", label: "Failed", description: "Needs attention", tone: "destructive" },
|
|
45
|
+
{
|
|
46
|
+
key: "compensated",
|
|
47
|
+
label: "Compensated",
|
|
48
|
+
description: "Rolled back",
|
|
49
|
+
tone: "outline",
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const LINKS_REFETCH_MS = 15_000;
|
|
53
|
+
const THROTTLING_REFETCH_MS = 60_000;
|
|
54
|
+
// Page
|
|
55
|
+
export function ChannelSyncPage({ baseUrl, fetcher, className } = {}) {
|
|
56
|
+
const context = useVoyantDistributionContext();
|
|
57
|
+
const client = {
|
|
58
|
+
baseUrl: baseUrl ?? context.baseUrl,
|
|
59
|
+
fetcher: fetcher ?? context.fetcher ?? defaultFetcher,
|
|
60
|
+
};
|
|
61
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
62
|
+
const [bookingId, setBookingId] = useState(null);
|
|
63
|
+
const [bookingSearch, setBookingSearch] = useState("");
|
|
64
|
+
const [selectedBooking, setSelectedBooking] = useState(null);
|
|
65
|
+
const [channelId, setChannelId] = useState(null);
|
|
66
|
+
const [selectedChannel, setSelectedChannel] = useState(null);
|
|
67
|
+
const [drilldownBookingId, setDrilldownBookingId] = useState(null);
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
const linksQuery = useQuery({
|
|
70
|
+
queryKey: ["channel-push-links", statusFilter, bookingId, channelId],
|
|
71
|
+
queryFn: () => {
|
|
72
|
+
const params = new URLSearchParams({ limit: "100" });
|
|
73
|
+
if (statusFilter !== "all")
|
|
74
|
+
params.set("status", statusFilter);
|
|
75
|
+
if (bookingId)
|
|
76
|
+
params.set("bookingId", bookingId);
|
|
77
|
+
if (channelId)
|
|
78
|
+
params.set("channelId", channelId);
|
|
79
|
+
return fetchJson(`/v1/admin/distribution/channel-push/links?${params}`, client);
|
|
80
|
+
},
|
|
81
|
+
refetchInterval: LINKS_REFETCH_MS,
|
|
82
|
+
refetchIntervalInBackground: false,
|
|
83
|
+
});
|
|
84
|
+
const throttlingQuery = useQuery({
|
|
85
|
+
queryKey: ["channel-push-throttling"],
|
|
86
|
+
queryFn: () => fetchJson("/v1/admin/distribution/channel-push/throttling", client),
|
|
87
|
+
refetchInterval: THROTTLING_REFETCH_MS,
|
|
88
|
+
});
|
|
89
|
+
const debouncedBookingSearch = useDebouncedValue(bookingSearch, 200);
|
|
90
|
+
const bookingsQuery = useQuery({
|
|
91
|
+
queryKey: ["channel-sync-booking-options", debouncedBookingSearch],
|
|
92
|
+
queryFn: () => {
|
|
93
|
+
const params = new URLSearchParams({ limit: "20" });
|
|
94
|
+
if (debouncedBookingSearch.trim())
|
|
95
|
+
params.set("search", debouncedBookingSearch.trim());
|
|
96
|
+
return fetchJson(`/v1/admin/bookings?${params}`, client);
|
|
97
|
+
},
|
|
98
|
+
placeholderData: (prev) => prev,
|
|
99
|
+
});
|
|
100
|
+
const channelsQuery = useQuery({
|
|
101
|
+
queryKey: ["channel-sync-channel-options"],
|
|
102
|
+
queryFn: () => fetchJson(`/v1/admin/distribution/channels?limit=100`, client),
|
|
103
|
+
staleTime: 60_000,
|
|
104
|
+
});
|
|
105
|
+
const retryMutation = useMutation({
|
|
106
|
+
mutationFn: (id) => fetchJson(`/v1/admin/distribution/channel-push/retry/${id}`, client, { method: "POST" }),
|
|
107
|
+
onSuccess: () => {
|
|
108
|
+
void queryClient.invalidateQueries({ queryKey: ["channel-push-links"] });
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const reconcileMutation = useMutation({
|
|
112
|
+
mutationFn: (flow) => fetchJson(`/v1/admin/distribution/channel-push/reconcile/${flow}`, client, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
}),
|
|
115
|
+
onSuccess: () => {
|
|
116
|
+
void queryClient.invalidateQueries({ queryKey: ["channel-push-links"] });
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const counts = linksQuery.data?.counts ?? {};
|
|
120
|
+
const rows = linksQuery.data?.data ?? [];
|
|
121
|
+
const throttledChannels = throttlingQuery.data?.data ?? [];
|
|
122
|
+
const isThrottled = throttledChannels.length > 0;
|
|
123
|
+
const filtersActive = statusFilter !== "all" || bookingId !== null || channelId !== null;
|
|
124
|
+
const clearFilters = () => {
|
|
125
|
+
setStatusFilter("all");
|
|
126
|
+
setBookingId(null);
|
|
127
|
+
setBookingSearch("");
|
|
128
|
+
setSelectedBooking(null);
|
|
129
|
+
setChannelId(null);
|
|
130
|
+
setSelectedChannel(null);
|
|
131
|
+
};
|
|
132
|
+
const bookingOptions = bookingsQuery.data?.data ?? [];
|
|
133
|
+
const channelOptions = channelsQuery.data?.data ?? [];
|
|
134
|
+
return (_jsxs("div", { "data-slot": "channel-sync-page", className: cn("flex flex-col gap-6 p-6", className), children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold tracking-tight", children: "Channel sync" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Outbound delivery to syndication channels. Bookings push first; availability and content ride along in the background." })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(AutoRefreshIndicator, { isFetching: linksQuery.isFetching, dataUpdatedAt: linksQuery.dataUpdatedAt, intervalMs: LINKS_REFETCH_MS }), _jsx(ReconcileMenu, { onRun: (flow) => reconcileMutation.mutate(flow), isRunning: reconcileMutation.isPending, lastResult: reconcileMutation.data ?? null })] })] }), isThrottled ? (_jsxs("div", { className: "flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0" }), _jsxs("div", { children: [_jsx("span", { className: "font-medium", children: "Throttled." }), " ", _jsxs("span", { children: [throttledChannels.reduce((sum, c) => sum + c.count, 0), " rate-limited deliveries in the last hour across ", throttledChannels.length, " channel", throttledChannels.length === 1 ? "" : "s", ". Lower the per-channel RPS in settings if this persists."] })] })] })) : null, _jsx("div", { className: "grid grid-cols-2 gap-3 md:grid-cols-4", children: STATUS_TILES.map((tile) => {
|
|
135
|
+
const isActive = statusFilter === tile.key;
|
|
136
|
+
const value = counts[tile.key] ?? 0;
|
|
137
|
+
return (_jsxs("button", { type: "button", onClick: () => setStatusFilter(isActive ? "all" : tile.key), className: cn("group rounded-lg border bg-card p-4 text-left transition-all", "hover:border-foreground/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", isActive && "border-primary ring-2 ring-primary/30"), "aria-pressed": isActive, children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: tile.label }), tile.key === "failed" && value > 0 ? (_jsx(AlertTriangle, { className: "h-3.5 w-3.5 text-destructive" })) : null] }), _jsx("div", { className: "mt-1 text-3xl font-semibold tabular-nums", children: value }), _jsx("div", { className: "mt-1 text-xs text-muted-foreground", children: tile.description })] }, tile.key));
|
|
138
|
+
}) }), _jsxs("div", { className: "flex flex-col gap-3 md:flex-row md:items-end", children: [_jsxs("div", { className: "flex flex-1 flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "cs-booking", className: "text-xs", children: "Booking" }), _jsx(AsyncCombobox, { value: bookingId, onChange: (value) => {
|
|
139
|
+
setBookingId(value);
|
|
140
|
+
if (!value)
|
|
141
|
+
setSelectedBooking(null);
|
|
142
|
+
else {
|
|
143
|
+
const match = bookingOptions.find((b) => b.id === value);
|
|
144
|
+
if (match)
|
|
145
|
+
setSelectedBooking(match);
|
|
146
|
+
}
|
|
147
|
+
}, items: bookingOptions, selectedItem: selectedBooking, getKey: (b) => b.id, getLabel: (b) => b.bookingNumber, getSecondary: (b) => b.status, onSearchChange: setBookingSearch, placeholder: "Search by booking number...", emptyText: bookingsQuery.isFetching ? "Searching..." : "No bookings match that search.", triggerClassName: "w-full" })] }), _jsxs("div", { className: "flex flex-1 flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "cs-channel", className: "text-xs", children: "Channel" }), _jsx(AsyncCombobox, { value: channelId, onChange: (value) => {
|
|
148
|
+
setChannelId(value);
|
|
149
|
+
if (!value)
|
|
150
|
+
setSelectedChannel(null);
|
|
151
|
+
else {
|
|
152
|
+
const match = channelOptions.find((c) => c.id === value);
|
|
153
|
+
if (match)
|
|
154
|
+
setSelectedChannel(match);
|
|
155
|
+
}
|
|
156
|
+
}, items: channelOptions, selectedItem: selectedChannel, getKey: (c) => c.id, getLabel: (c) => c.name, getSecondary: (c) => formatChannelKind(c.kind), placeholder: "Pick a channel...", emptyText: "No channels configured yet.", triggerClassName: "w-full" })] }), filtersActive ? (_jsxs(Button, { variant: "ghost", size: "sm", onClick: clearFilters, children: [_jsx(X, { className: "mr-1.5 h-3.5 w-3.5" }), "Clear filters"] })) : null] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "pb-3", children: [_jsx(CardTitle, { className: "text-sm", children: "Booking links" }), _jsx(CardDescription, { children: filtersActive
|
|
157
|
+
? `Showing ${rows.length} of the most recent matching pushes.`
|
|
158
|
+
: "The most recent per-channel push attempts." })] }), _jsx(CardContent, { className: "p-0", children: linksQuery.isPending ? (_jsx("div", { className: "flex items-center justify-center p-12", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) })) : rows.length === 0 ? (_jsx(Empty, { className: "border-0", children: _jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: filtersActive ? "No matches" : "No links yet" }), _jsx(EmptyDescription, { children: filtersActive
|
|
159
|
+
? "Try clearing the filters or picking a different booking or channel."
|
|
160
|
+
: "Channel-push booking links show up here as bookings confirm. The page refreshes automatically." })] }) })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Booking" }), _jsx(TableHead, { children: "Channel" }), _jsx(TableHead, { children: "Status" }), _jsx(TableHead, { className: "text-right", children: "Attempts" }), _jsx(TableHead, { children: "Last push" }), _jsx(TableHead, { children: "External ref" }), _jsx(TableHead, { className: "text-right", children: "Actions" })] }) }), _jsx(TableBody, { children: rows.map((row) => {
|
|
161
|
+
const isFailed = row.link.pushStatus === "failed";
|
|
162
|
+
return (_jsxs(TableRow, { className: cn(isFailed && "bg-destructive/5 hover:bg-destructive/10"), children: [_jsxs(TableCell, { className: "font-mono text-xs", children: [_jsx("div", { children: row.link.bookingId }), row.link.bookingItemId ? (_jsxs("div", { className: "text-muted-foreground", children: ["item: ", row.link.bookingItemId] })) : null] }), _jsxs(TableCell, { children: [_jsx("div", { className: "font-medium", children: row.channelName }), _jsx("div", { className: "text-xs text-muted-foreground", children: formatChannelKind(row.channelKind) })] }), _jsxs(TableCell, { children: [_jsx(Badge, { variant: STATUS_VARIANTS[row.link.pushStatus] ?? "outline", children: STATUS_LABELS[row.link.pushStatus] ?? row.link.pushStatus }), row.link.lastError ? (_jsx("div", { className: "mt-1 max-w-xs truncate text-xs text-destructive", title: row.link.lastError, children: row.link.lastError })) : null] }), _jsx(TableCell, { className: "text-right tabular-nums", children: row.link.pushAttempts }), _jsx(TableCell, { className: "text-xs text-muted-foreground", children: row.link.lastPushAt ? formatRelative(row.link.lastPushAt) : "-" }), _jsx(TableCell, { className: "font-mono text-xs", children: row.link.externalBookingId ?? row.link.externalReference ?? "-" }), _jsx(TableCell, { className: "text-right", children: _jsxs("div", { className: "flex justify-end gap-1", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDrilldownBookingId(row.link.bookingId), children: "Deliveries" }), _jsxs(Button, { variant: "outline", size: "sm", disabled: retryMutation.isPending &&
|
|
163
|
+
retryMutation.variables === row.link.bookingId, onClick: () => retryMutation.mutate(row.link.bookingId), children: [retryMutation.isPending &&
|
|
164
|
+
retryMutation.variables === row.link.bookingId ? (_jsx(Loader2, { className: "mr-1 h-3 w-3 animate-spin" })) : null, "Retry"] })] }) })] }, row.link.id));
|
|
165
|
+
}) })] })) })] }), _jsx(DeliveriesDrawer, { bookingId: drilldownBookingId, client: client, onClose: () => setDrilldownBookingId(null) })] }));
|
|
166
|
+
}
|
|
167
|
+
function ReconcileMenu({ onRun, isRunning, lastResult, }) {
|
|
168
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsxs(Button, { variant: "outline", size: "sm", disabled: isRunning, children: [isRunning ? (_jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" })) : (_jsx(RotateCw, { className: "mr-1.5 h-3.5 w-3.5" })), "Reconcile", _jsx(ChevronDown, { className: "ml-1.5 h-3.5 w-3.5" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Run reconciler" }), _jsxs(DropdownMenuItem, { onClick: () => onRun("bookings"), children: ["Bookings", _jsx("span", { className: "ml-auto text-xs text-muted-foreground", children: "priority" })] }), _jsx(DropdownMenuItem, { onClick: () => onRun("availability"), children: "Availability" }), _jsx(DropdownMenuItem, { onClick: () => onRun("content"), children: "Content" })] }), lastResult ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs("div", { className: "px-2 py-1.5 text-xs text-muted-foreground", children: ["Last run: scanned ", lastResult.scanned, ", triggered ", lastResult.triggered, "."] })] })) : null] })] }));
|
|
169
|
+
}
|
|
170
|
+
function AutoRefreshIndicator({ isFetching, dataUpdatedAt, intervalMs, }) {
|
|
171
|
+
// Tick every second so the "Updated Xs ago" stays current.
|
|
172
|
+
const [, setNow] = useState(Date.now());
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
175
|
+
return () => window.clearInterval(id);
|
|
176
|
+
}, []);
|
|
177
|
+
if (!dataUpdatedAt) {
|
|
178
|
+
return (_jsxs("span", { className: "hidden items-center gap-1.5 text-xs text-muted-foreground md:flex", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), "Loading..."] }));
|
|
179
|
+
}
|
|
180
|
+
const seconds = Math.max(0, Math.round((Date.now() - dataUpdatedAt) / 1000));
|
|
181
|
+
const intervalSec = Math.round(intervalMs / 1000);
|
|
182
|
+
return (_jsxs("span", { className: "hidden items-center gap-1.5 text-xs text-muted-foreground md:flex", title: `Auto-refreshes every ${intervalSec}s`, children: [isFetching ? (_jsx(Loader2, { className: "h-3 w-3 animate-spin" })) : (_jsxs("span", { className: "relative flex h-2 w-2", children: [_jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" }), _jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-emerald-500" })] })), _jsx("span", { className: "tabular-nums", children: isFetching ? "Refreshing..." : `Updated ${formatShortDuration(seconds)} ago` })] }));
|
|
183
|
+
}
|
|
184
|
+
function useDebouncedValue(value, delayMs) {
|
|
185
|
+
const [debounced, setDebounced] = useState(value);
|
|
186
|
+
const timeoutRef = useRef(null);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (timeoutRef.current)
|
|
189
|
+
clearTimeout(timeoutRef.current);
|
|
190
|
+
timeoutRef.current = setTimeout(() => setDebounced(value), delayMs);
|
|
191
|
+
return () => {
|
|
192
|
+
if (timeoutRef.current)
|
|
193
|
+
clearTimeout(timeoutRef.current);
|
|
194
|
+
};
|
|
195
|
+
}, [value, delayMs]);
|
|
196
|
+
return debounced;
|
|
197
|
+
}
|
|
198
|
+
function DeliveriesDrawer({ bookingId, client, onClose, }) {
|
|
199
|
+
const isOpen = bookingId !== null;
|
|
200
|
+
const query = useQuery({
|
|
201
|
+
enabled: isOpen,
|
|
202
|
+
queryKey: ["channel-push-deliveries", bookingId],
|
|
203
|
+
queryFn: () => {
|
|
204
|
+
const params = new URLSearchParams({ bookingId: bookingId ?? "", limit: "200" });
|
|
205
|
+
return fetchJson(`/v1/admin/distribution/channel-push/deliveries?${params}`, client);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const rows = query.data?.data ?? [];
|
|
209
|
+
return (_jsx(Sheet, { open: isOpen, onOpenChange: (open) => (open ? null : onClose()), children: _jsxs(SheetContent, { side: "right", size: "xl", children: [_jsx(SheetHeader, { children: _jsxs(SheetTitle, { children: ["Delivery log - ", bookingId ?? ""] }) }), _jsx(SheetBody, { className: "flex flex-col gap-3", children: query.isPending ? (_jsx("div", { className: "flex items-center justify-center p-12", children: _jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }) })) : rows.length === 0 ? (_jsx(Empty, { children: _jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: "No deliveries yet" }), _jsx(EmptyDescription, { children: "Channel-push attempts log here once they dispatch." })] }) })) : (rows.map((row) => (_jsxs(Card, { className: "text-xs", children: [_jsxs(CardHeader, { className: "pb-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: row.status === "succeeded" ? "default" : "destructive", children: row.status }), _jsx("span", { className: "font-mono", children: row.sourceEvent }), _jsxs("span", { className: "text-muted-foreground", children: ["attempt #", row.attemptNumber] })] }), _jsx("span", { className: "text-muted-foreground", children: row.durationMs != null ? `${row.durationMs}ms` : "" })] }), _jsxs(CardDescription, { className: "font-mono", children: [row.requestMethod, " ", row.targetUrl] })] }), _jsxs(CardContent, { className: "space-y-2", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [row.responseStatus != null ? (_jsxs(Badge, { variant: "outline", children: ["HTTP ", row.responseStatus] })) : null, row.errorClass ? _jsx(Badge, { variant: "destructive", children: row.errorClass }) : null, _jsx("span", { className: "text-muted-foreground", children: formatRelative(row.createdAt) })] }), row.errorMessage ? (_jsx("pre", { className: "overflow-x-auto whitespace-pre-wrap rounded bg-destructive/10 p-2 text-destructive", children: row.errorMessage })) : null, row.responseBodyExcerpt ? (_jsx("pre", { className: "overflow-x-auto rounded bg-muted p-2", children: row.responseBodyExcerpt })) : null] })] }, row.id)))) })] }) }));
|
|
210
|
+
}
|
|
211
|
+
function joinUrl(baseUrl, path) {
|
|
212
|
+
const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
213
|
+
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
214
|
+
return `${trimmedBase}${trimmedPath}`;
|
|
215
|
+
}
|
|
216
|
+
function formatChannelKind(kind) {
|
|
217
|
+
return kind.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
|
|
218
|
+
}
|
|
219
|
+
function formatShortDuration(seconds) {
|
|
220
|
+
if (seconds < 60)
|
|
221
|
+
return `${seconds}s`;
|
|
222
|
+
const min = Math.round(seconds / 60);
|
|
223
|
+
if (min < 60)
|
|
224
|
+
return `${min}m`;
|
|
225
|
+
const hours = Math.round(min / 60);
|
|
226
|
+
return `${hours}h`;
|
|
227
|
+
}
|
|
228
|
+
function formatRelative(iso) {
|
|
229
|
+
const date = new Date(iso);
|
|
230
|
+
const diffMs = Date.now() - date.getTime();
|
|
231
|
+
const sec = Math.round(diffMs / 1000);
|
|
232
|
+
if (sec < 60)
|
|
233
|
+
return `${sec}s ago`;
|
|
234
|
+
const min = Math.round(sec / 60);
|
|
235
|
+
if (min < 60)
|
|
236
|
+
return `${min}m ago`;
|
|
237
|
+
const hours = Math.round(min / 60);
|
|
238
|
+
if (hours < 24)
|
|
239
|
+
return `${hours}h ago`;
|
|
240
|
+
const days = Math.round(hours / 24);
|
|
241
|
+
return `${days}d ago`;
|
|
242
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channels-page.d.ts","sourceRoot":"","sources":["../../src/components/channels-page.tsx"],"names":[],"mappings":"AAiDA,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAWD,wBAAgB,YAAY,CAAC,EAAE,SAAS,EAAE,QAAoB,EAAE,GAAE,iBAAsB,2CAgJvF"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useChannelMutation, useChannels, } from "@voyantjs/distribution-react";
|
|
4
|
+
import { Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetBody, SheetContent, SheetFooter, SheetHeader, SheetTitle, } from "@voyantjs/ui/components";
|
|
5
|
+
import { cn } from "@voyantjs/ui/lib/utils";
|
|
6
|
+
import { Loader2, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { useDistributionUiI18nOrDefault } from "../i18n/index.js";
|
|
9
|
+
const PAGE_SIZE = 25;
|
|
10
|
+
const defaultFormValues = {
|
|
11
|
+
name: "",
|
|
12
|
+
kind: "direct",
|
|
13
|
+
status: "active",
|
|
14
|
+
website: "",
|
|
15
|
+
contactName: "",
|
|
16
|
+
contactEmail: "",
|
|
17
|
+
};
|
|
18
|
+
export function ChannelsPage({ className, pageSize = PAGE_SIZE } = {}) {
|
|
19
|
+
const { messages } = useDistributionUiI18nOrDefault();
|
|
20
|
+
const page = messages.settings.channelsPage;
|
|
21
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
22
|
+
const [editing, setEditing] = useState();
|
|
23
|
+
const [pageIndex, setPageIndex] = useState(0);
|
|
24
|
+
const { data, isPending, refetch } = useChannels({
|
|
25
|
+
limit: pageSize,
|
|
26
|
+
offset: pageIndex * pageSize,
|
|
27
|
+
});
|
|
28
|
+
const { remove } = useChannelMutation();
|
|
29
|
+
const channels = data?.data ?? [];
|
|
30
|
+
const total = data?.total ?? 0;
|
|
31
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
32
|
+
return (_jsxs("div", { "data-slot": "channels-page", className: cn("flex flex-col gap-6 p-6", className), children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-lg font-semibold tracking-tight", children: page.title }), _jsx("p", { className: "text-sm text-muted-foreground", children: page.description })] }), _jsxs(Button, { size: "sm", onClick: () => {
|
|
33
|
+
setEditing(undefined);
|
|
34
|
+
setSheetOpen(true);
|
|
35
|
+
}, children: [_jsx(Plus, { className: "mr-1.5 h-3.5 w-3.5" }), page.addChannel] })] }), isPending ? (_jsx(ChannelsListSkeleton, {})) : (_jsx("div", { className: "rounded-lg border bg-card text-card-foreground shadow-sm", children: channels.length === 0 ? (_jsx("p", { className: "py-12 text-center text-sm text-muted-foreground", children: page.empty })) : (_jsx("div", { className: "flex flex-col divide-y", children: channels.map((channel) => (_jsxs("div", { className: "flex items-center justify-between px-6 py-3", children: [_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-sm font-medium", children: channel.name }), _jsx(Badge, { variant: "outline", className: "text-xs", children: messages.common.channelKindLabels[channel.kind] }), channel.status !== "active" ? (_jsx(Badge, { variant: "secondary", className: "text-xs", children: messages.common.channelStatusLabels[channel.status] })) : null] }), _jsxs("div", { className: "flex flex-wrap gap-3 text-xs text-muted-foreground", children: [channel.website ? _jsx("span", { children: channel.website }) : null, channel.contactName ? _jsx("span", { children: channel.contactName }) : null, channel.contactEmail ? _jsx("span", { children: channel.contactEmail }) : null] })] }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 text-muted-foreground", children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsxs(DropdownMenuItem, { onClick: () => {
|
|
36
|
+
setEditing(channel);
|
|
37
|
+
setSheetOpen(true);
|
|
38
|
+
}, children: [_jsx(Pencil, { className: "h-4 w-4" }), page.edit] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { variant: "destructive", disabled: remove.isPending, onClick: () => {
|
|
39
|
+
if (window.confirm(page.deleteConfirm)) {
|
|
40
|
+
void remove.mutateAsync(channel.id).then(() => refetch());
|
|
41
|
+
}
|
|
42
|
+
}, children: [_jsx(Trash2, { className: "h-4 w-4" }), page.delete] })] })] })] }, channel.id))) })) })), _jsxs("div", { className: "flex items-center justify-between gap-4 text-sm text-muted-foreground", children: [_jsx("span", { children: page.paginationShowing
|
|
43
|
+
.replace("{count}", String(channels.length))
|
|
44
|
+
.replace("{total}", String(total)) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "sm", disabled: pageIndex === 0, onClick: () => setPageIndex((current) => Math.max(0, current - 1)), children: page.paginationPrevious }), _jsx("span", { children: page.paginationPage
|
|
45
|
+
.replace("{page}", String(pageIndex + 1))
|
|
46
|
+
.replace("{pageCount}", String(pageCount)) }), _jsx(Button, { variant: "outline", size: "sm", disabled: (pageIndex + 1) * pageSize >= total, onClick: () => setPageIndex((current) => current + 1), children: page.paginationNext })] })] }), _jsx(ChannelSheet, { open: sheetOpen, onOpenChange: setSheetOpen, channel: editing, onSuccess: () => {
|
|
47
|
+
setSheetOpen(false);
|
|
48
|
+
setEditing(undefined);
|
|
49
|
+
void refetch();
|
|
50
|
+
} })] }));
|
|
51
|
+
}
|
|
52
|
+
function ChannelSheet({ open, onOpenChange, channel, onSuccess, }) {
|
|
53
|
+
const { messages } = useDistributionUiI18nOrDefault();
|
|
54
|
+
const page = messages.settings.channelsPage;
|
|
55
|
+
const isEditing = !!channel;
|
|
56
|
+
const { create, update } = useChannelMutation();
|
|
57
|
+
const [values, setValues] = useState(defaultFormValues);
|
|
58
|
+
const [errors, setErrors] = useState({});
|
|
59
|
+
const channelKinds = Object.entries(messages.common.channelKindLabels).map(([value, label]) => ({
|
|
60
|
+
value: value,
|
|
61
|
+
label,
|
|
62
|
+
}));
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (open && channel) {
|
|
65
|
+
setValues({
|
|
66
|
+
name: channel.name,
|
|
67
|
+
kind: channel.kind,
|
|
68
|
+
status: channel.status,
|
|
69
|
+
website: channel.website ?? "",
|
|
70
|
+
contactName: channel.contactName ?? "",
|
|
71
|
+
contactEmail: channel.contactEmail ?? "",
|
|
72
|
+
});
|
|
73
|
+
setErrors({});
|
|
74
|
+
}
|
|
75
|
+
else if (open) {
|
|
76
|
+
setValues(defaultFormValues);
|
|
77
|
+
setErrors({});
|
|
78
|
+
}
|
|
79
|
+
}, [open, channel]);
|
|
80
|
+
const isSubmitting = create.isPending || update.isPending;
|
|
81
|
+
const setValue = (key, value) => setValues((current) => ({ ...current, [key]: value }));
|
|
82
|
+
const onSubmit = async (event) => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
const nextErrors = validateChannelForm(values, page);
|
|
85
|
+
setErrors(nextErrors);
|
|
86
|
+
if (Object.keys(nextErrors).length > 0)
|
|
87
|
+
return;
|
|
88
|
+
const payload = {
|
|
89
|
+
name: values.name.trim(),
|
|
90
|
+
kind: values.kind,
|
|
91
|
+
status: values.status,
|
|
92
|
+
website: normalizeOptional(values.website),
|
|
93
|
+
contactName: normalizeOptional(values.contactName),
|
|
94
|
+
contactEmail: normalizeOptional(values.contactEmail),
|
|
95
|
+
};
|
|
96
|
+
if (isEditing) {
|
|
97
|
+
await update.mutateAsync({ id: channel.id, input: payload });
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
await create.mutateAsync(payload);
|
|
101
|
+
}
|
|
102
|
+
onSuccess();
|
|
103
|
+
};
|
|
104
|
+
return (_jsx(Sheet, { open: open, onOpenChange: onOpenChange, children: _jsxs(SheetContent, { side: "right", size: "lg", children: [_jsx(SheetHeader, { children: _jsx(SheetTitle, { children: isEditing ? page.editSheetTitle : page.newSheetTitle }) }), _jsxs("form", { onSubmit: onSubmit, className: "flex flex-1 flex-col overflow-hidden", children: [_jsxs(SheetBody, { className: "grid gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.nameLabel }), _jsx(Input, { value: values.name, onChange: (event) => setValue("name", event.target.value), placeholder: page.namePlaceholder, autoFocus: true }), errors.name ? _jsx("p", { className: "text-xs text-destructive", children: errors.name }) : null] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.kindLabel }), _jsxs(Select, { items: channelKinds, value: values.kind, onValueChange: (value) => setValue("kind", value), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: channelKinds.map((kind) => (_jsx(SelectItem, { value: kind.value, children: kind.label }, kind.value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.statusLabel }), _jsxs(Select, { value: values.status, onValueChange: (value) => setValue("status", value), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: Object.entries(messages.common.channelStatusLabels).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value))) })] })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.websiteLabel }), _jsx(Input, { value: values.website, onChange: (event) => setValue("website", event.target.value), placeholder: page.websitePlaceholder }), errors.website ? _jsx("p", { className: "text-xs text-destructive", children: errors.website }) : null] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.primaryContactLabel }), _jsx(Input, { value: values.contactName, onChange: (event) => setValue("contactName", event.target.value), placeholder: page.primaryContactPlaceholder })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: page.contactEmailLabel }), _jsx(Input, { value: values.contactEmail, onChange: (event) => setValue("contactEmail", event.target.value), placeholder: page.contactEmailPlaceholder }), errors.contactEmail ? (_jsx("p", { className: "text-xs text-destructive", children: errors.contactEmail })) : null] })] })] }), _jsxs(SheetFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onOpenChange(false), children: messages.common.cancel }), _jsxs(Button, { type: "submit", size: "sm", disabled: isSubmitting, children: [isSubmitting ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, isEditing ? page.saveChanges : page.createChannel] })] })] })] }) }));
|
|
105
|
+
}
|
|
106
|
+
function ChannelsListSkeleton() {
|
|
107
|
+
const rows = ["first", "second", "third", "fourth", "fifth"];
|
|
108
|
+
return (_jsx("div", { className: "rounded-lg border bg-card text-card-foreground shadow-sm", children: rows.map((row) => (_jsxs("div", { className: "flex items-center justify-between border-b px-6 py-3 last:border-b-0", children: [_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "h-4 w-44 rounded bg-muted" }), _jsx("div", { className: "h-3 w-64 rounded bg-muted" })] }), _jsx("div", { className: "h-8 w-8 rounded bg-muted" })] }, row))) }));
|
|
109
|
+
}
|
|
110
|
+
function validateChannelForm(values, page) {
|
|
111
|
+
const errors = {};
|
|
112
|
+
if (!values.name.trim())
|
|
113
|
+
errors.name = page.validationNameRequired;
|
|
114
|
+
if (values.name.length > 255)
|
|
115
|
+
errors.name = page.validationNameRequired;
|
|
116
|
+
if (values.website.trim()) {
|
|
117
|
+
try {
|
|
118
|
+
new URL(values.website.trim());
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
errors.website = page.validationInvalidUrl;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (values.contactEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.contactEmail)) {
|
|
125
|
+
errors.contactEmail = page.validationInvalidEmail;
|
|
126
|
+
}
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
function normalizeOptional(value) {
|
|
130
|
+
const trimmed = value.trim();
|
|
131
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
132
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CommissionRuleDetailPageProps {
|
|
2
|
+
id: string;
|
|
3
|
+
className?: string;
|
|
4
|
+
onBack?: () => void;
|
|
5
|
+
onDeleted?: () => void;
|
|
6
|
+
onContractOpen?: (contractId: string) => void;
|
|
7
|
+
onProductOpen?: (productId: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare function CommissionRuleDetailPage({ id, className, onBack, onDeleted, onContractOpen, onProductOpen, }: CommissionRuleDetailPageProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
//# sourceMappingURL=commission-rule-detail-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commission-rule-detail-page.d.ts","sourceRoot":"","sources":["../../src/components/commission-rule-detail-page.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,6BAA6B;IAC5C,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,cAAc,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAA;IAC7C,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5C;AAID,wBAAgB,wBAAwB,CAAC,EACvC,EAAE,EACF,SAAS,EACT,MAAa,EACb,SAAgB,EAChB,cAAqB,EACrB,aAAoB,GACrB,EAAE,6BAA6B,2CAgK/B"}
|