refactor: overhaul link system with dynamic filters and unified Links component

This commit is contained in:
Junaid
2025-12-18 18:40:11 -06:00
parent ea3a70b198
commit a9724ec43d
21 changed files with 2278 additions and 2504 deletions
+95 -12
View File
@@ -4410,18 +4410,25 @@ async function applyDynamicWhereClause(context, linkId, orderId) {
where: { id: linkId },
query: "id dynamicWhereClause"
});
if (!link || !link.dynamicWhereClause) {
if (!link) {
return null;
}
const whereClause = {
...link.dynamicWhereClause,
id: { equals: orderId }
};
const matchedOrder = await context.query.Order.findOne({
where: whereClause,
query: "id"
const dynamicWhere = link.dynamicWhereClause;
if (!dynamicWhere || typeof dynamicWhere !== "object" || Object.keys(dynamicWhere).length === 0) {
return await context.query.Order.findOne({
where: { id: orderId },
query: "id"
});
}
const matchedOrders = await context.query.Order.findMany({
where: {
...dynamicWhere,
id: { equals: orderId }
},
query: "id",
take: 1
});
return matchedOrder;
return matchedOrders.length > 0 ? matchedOrders[0] : null;
}
var Order = (0, import_core4.list)({
access: {
@@ -6287,9 +6294,85 @@ var Link = (0, import_core16.list)({
// Virtual field for dynamic where clause
dynamicWhereClause: (0, import_fields16.virtual)({
field: import_core17.graphql.field({
type: import_core17.graphql.String,
resolve() {
return "Generated where clause based on filters";
type: import_core17.graphql.JSON,
async resolve(item, args, context) {
const link = await context.query.Link.findOne({
where: { id: item.id },
query: "filters"
});
const filters = link?.filters;
if (!filters || !Array.isArray(filters) || filters.length === 0) {
return {};
}
const whereConditions = [];
for (const filter of filters) {
if (!filter.field || !filter.type || filter.value === void 0) {
continue;
}
const { field: fieldPath, type, value } = filter;
if (type.endsWith("_i")) {
const isNot = type.startsWith("not_");
const key = type === "is_i" || type === "not_i" ? "equals" : type.replace(/_i$/, "").replace("not_", "").replace(/_([a-z])/g, (_, char) => char.toUpperCase());
const baseFilter = {
[key]: value,
mode: "insensitive"
};
whereConditions.push({
[fieldPath]: isNot ? { not: baseFilter } : baseFilter
});
} else if (["equals", "gt", "lt", "gte", "lte", "in", "notIn"].includes(type)) {
whereConditions.push({
[fieldPath]: { [type]: value }
});
} else if (type === "not") {
whereConditions.push({
[fieldPath]: { not: { equals: value } }
});
} else if (type === "empty") {
whereConditions.push({
[fieldPath]: { equals: null }
});
} else if (type === "not_empty") {
whereConditions.push({
[fieldPath]: { not: { equals: null } }
});
} else if (type === "is") {
whereConditions.push({
[fieldPath]: { id: { equals: value } }
});
} else if (type === "not_is") {
whereConditions.push({
[fieldPath]: { not: { id: { equals: value } } }
});
} else if (type === "some") {
whereConditions.push({
[fieldPath]: { some: { id: { in: Array.isArray(value) ? value : [value] } } }
});
} else if (type === "not_some") {
whereConditions.push({
[fieldPath]: { not: { some: { id: { in: Array.isArray(value) ? value : [value] } } } }
});
} else if (type === "matches") {
whereConditions.push({
[fieldPath]: { in: Array.isArray(value) ? value : [value] }
});
} else if (type === "not_matches") {
whereConditions.push({
[fieldPath]: { notIn: Array.isArray(value) ? value : [value] }
});
} else {
whereConditions.push({
[fieldPath]: { [type]: value }
});
}
}
if (whereConditions.length === 0) {
return {};
}
if (whereConditions.length === 1) {
return whereConditions[0];
}
return { AND: whereConditions };
}
}),
ui: {
File diff suppressed because one or more lines are too long
+121 -3
View File
@@ -86,9 +86,127 @@ export const Link = list({
// Virtual field for dynamic where clause
dynamicWhereClause: virtual({
field: graphql.field({
type: graphql.String,
resolve() {
return "Generated where clause based on filters";
type: graphql.JSON,
async resolve(item: any, args, context) {
// Get the filters from the item
const link = await context.query.Link.findOne({
where: { id: item.id },
query: 'filters',
});
const filters = link?.filters;
// If no filters or not an array, return empty object (match all)
if (!filters || !Array.isArray(filters) || filters.length === 0) {
return {};
}
// Convert filters array to Keystone where clause format
// Using the same logic as buildWhereClause.ts
const whereConditions: Record<string, any>[] = [];
for (const filter of filters) {
if (!filter.field || !filter.type || filter.value === undefined) {
continue;
}
const { field: fieldPath, type, value } = filter;
// Handle text field filter types (contains_i, is_i, starts_with_i, etc.)
if (type.endsWith('_i')) {
const isNot = type.startsWith('not_');
// Convert filter type to GraphQL key
// e.g., contains_i -> contains, not_contains_i -> contains (with not wrapper)
// e.g., is_i -> equals, starts_with_i -> startsWith
const key = type === 'is_i' || type === 'not_i'
? 'equals'
: type
.replace(/_i$/, '')
.replace('not_', '')
.replace(/_([a-z])/g, (_: string, char: string) => char.toUpperCase());
const baseFilter = {
[key]: value,
mode: 'insensitive'
};
whereConditions.push({
[fieldPath]: isNot ? { not: baseFilter } : baseFilter,
});
}
// Handle other filter types (equals, gt, lt, etc.)
else if (['equals', 'gt', 'lt', 'gte', 'lte', 'in', 'notIn'].includes(type)) {
whereConditions.push({
[fieldPath]: { [type]: value },
});
}
// Handle not filter
else if (type === 'not') {
whereConditions.push({
[fieldPath]: { not: { equals: value } },
});
}
// Handle empty/not_empty
else if (type === 'empty') {
whereConditions.push({
[fieldPath]: { equals: null },
});
}
else if (type === 'not_empty') {
whereConditions.push({
[fieldPath]: { not: { equals: null } },
});
}
// Handle relationship filters
else if (type === 'is') {
whereConditions.push({
[fieldPath]: { id: { equals: value } },
});
}
else if (type === 'not_is') {
whereConditions.push({
[fieldPath]: { not: { id: { equals: value } } },
});
}
else if (type === 'some') {
whereConditions.push({
[fieldPath]: { some: { id: { in: Array.isArray(value) ? value : [value] } } },
});
}
else if (type === 'not_some') {
whereConditions.push({
[fieldPath]: { not: { some: { id: { in: Array.isArray(value) ? value : [value] } } } },
});
}
// Handle select matches/not_matches
else if (type === 'matches') {
whereConditions.push({
[fieldPath]: { in: Array.isArray(value) ? value : [value] },
});
}
else if (type === 'not_matches') {
whereConditions.push({
[fieldPath]: { notIn: Array.isArray(value) ? value : [value] },
});
}
// Fallback for any other type
else {
whereConditions.push({
[fieldPath]: { [type]: value },
});
}
}
// Return combined where clause
if (whereConditions.length === 0) {
return {};
}
if (whereConditions.length === 1) {
return whereConditions[0];
}
return { AND: whereConditions };
},
}),
ui: {
+20 -8
View File
@@ -21,21 +21,33 @@ async function applyDynamicWhereClause(context: any, linkId: string, orderId: st
query: 'id dynamicWhereClause',
});
if (!link || !link.dynamicWhereClause) {
if (!link) {
return null;
}
const whereClause = {
...link.dynamicWhereClause,
id: { equals: orderId },
};
// dynamicWhereClause is now a virtual field that returns a proper where clause object
// If empty object or not set, it means match all orders
const dynamicWhere = link.dynamicWhereClause;
if (!dynamicWhere || typeof dynamicWhere !== 'object' || Object.keys(dynamicWhere).length === 0) {
// No filters means the link matches all orders - return the order
return await context.query.Order.findOne({
where: { id: orderId },
query: 'id',
});
}
const matchedOrder = await context.query.Order.findOne({
where: whereClause,
// Use findMany with the dynamic filters + id filter
const matchedOrders = await context.query.Order.findMany({
where: {
...dynamicWhere,
id: { equals: orderId },
},
query: 'id',
take: 1,
});
return matchedOrder;
return matchedOrders.length > 0 ? matchedOrders[0] : null;
}
export const Order = list({
@@ -34,6 +34,12 @@ export async function getChannels(
}
links {
id
shop {
id
name
}
filters
rank
}
`
) {
@@ -1,662 +0,0 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import {
ArrowRight,
Edit,
Edit2,
ListFilter,
Plus,
Trash2,
X,
ChevronDown,
Pencil,
CircleAlert,
GripVertical,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { ReactSortable } from "react-sortablejs";
import { cn } from "@/lib/utils";
interface LinkFilter {
field: string;
type: string;
value: string;
}
interface Link {
id: string;
shop: {
id: string;
name: string;
};
filters: LinkFilter[];
rank?: number;
createdAt: string;
}
interface Shop {
id: string;
name: string;
}
interface SortableLink extends Link {
chosen?: boolean;
selected?: boolean;
}
// Create Link Button Component
export const CreateLinkButton = ({ channelId, refetch }: {
channelId: string;
refetch: () => void;
}) => {
const [shops, setShops] = useState<Shop[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const { toast } = useToast();
const loadShops = async () => {
try {
setLoading(true);
const response = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query GetShops {
shops(take: 50) {
id
name
}
}
`
})
});
const data = await response.json();
if (data.data?.shops) {
setShops(data.data.shops);
} else {
setError('Failed to load shops');
}
} catch (err: any) {
console.error('Failed to load shops:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadShops();
}, []);
const handleCreateLink = async (shopId: string) => {
setIsCreating(true);
try {
const response = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
mutation CreateLink($data: LinkCreateInput!) {
createLink(data: $data) {
id
shop {
id
name
}
filters
rank
}
}
`,
variables: {
data: {
shop: { connect: { id: shopId } },
channel: { connect: { id: channelId } },
filters: {},
rank: 1
}
}
})
});
const data = await response.json();
if (data.data?.createLink) {
toast({
title: "Link Created",
description: "Successfully created shop link",
});
refetch();
} else {
throw new Error(data.errors?.[0]?.message || 'Failed to create link');
}
} catch (error: any) {
toast({
title: "Error",
description: error.message || "Failed to create link",
variant: "destructive",
});
} finally {
setIsCreating(false);
}
};
if (loading) {
return <Button disabled size="sm">Loading...</Button>;
}
if (error) {
return (
<div className="text-red-600 text-xs">
Error: {error}
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1" disabled={isCreating}>
<Plus className="h-3 w-3" />
{isCreating ? "Creating..." : "Add Link"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Select Shop to Link</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{shops.length === 0 ? (
<DropdownMenuItem disabled>
No shops available
</DropdownMenuItem>
) : (
shops.map((shop) => (
<DropdownMenuItem
key={shop.id}
onClick={() => handleCreateLink(shop.id)}
disabled={isCreating}
>
{shop.name}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Filter Editor Component
const FilterEditor = ({ filters, onChange }: {
filters: LinkFilter[];
onChange: (filters: LinkFilter[]) => void;
}) => {
const addFilter = () => {
onChange([...filters, { field: "", type: "equals", value: "" }]);
};
const updateFilter = (index: number, field: keyof LinkFilter, value: string) => {
const newFilters = [...filters];
newFilters[index] = { ...newFilters[index], [field]: value };
onChange(newFilters);
};
const removeFilter = (index: number) => {
onChange(filters.filter((_, i) => i !== index));
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Filters</Label>
<Button size="sm" variant="outline" onClick={addFilter}>
<Plus className="h-3 w-3 mr-1" />
Add Filter
</Button>
</div>
{filters.map((filter, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="Field"
value={filter.field}
onChange={(e) => updateFilter(index, "field", e.target.value)}
className="flex-1"
/>
<Select
value={filter.type}
onValueChange={(value) => updateFilter(index, "type", value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts With</SelectItem>
<SelectItem value="endsWith">Ends With</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Value"
value={filter.value}
onChange={(e) => updateFilter(index, "value", e.target.value)}
className="flex-1"
/>
<Button
size="sm"
variant="outline"
onClick={() => removeFilter(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
);
};
// Individual Link Item Component
const LinkItem = ({ link, linkMode = "sequential", isSelected, onSelect, onUpdate, onDelete }: {
link: SortableLink;
linkMode?: string;
isSelected: boolean;
onSelect: () => void;
onUpdate: (id: string, data: any) => void;
onDelete: (id: string) => void;
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDelete(link.id);
toast({
title: "Link Deleted",
description: "Successfully deleted link",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to delete link",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
return (
<div className="bg-background border rounded-lg flex justify-between items-center p-3 tracking-wide font-medium w-full">
<div className="flex items-center gap-3 flex-1">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<Badge
variant="outline"
className="py-1 px-2 text-xs font-medium"
>
{linkMode === "sequential" ? link.rank || 1 : "1"}
</Badge>
<div className="flex-1">
<div className="font-medium">{link.shop.name}</div>
<div className="text-xs text-muted-foreground">
{link.filters?.length || 0} filter{(link.filters?.length || 0) !== 1 && "s"}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className={cn(
"h-6 w-6 p-0",
isSelected && "bg-blue-50 border-blue-200"
)}
onClick={onSelect}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="secondary"
size="sm"
className={cn(
"h-6 px-2 text-xs",
isSelected && "bg-blue-50 border-blue-200 border"
)}
onClick={onSelect}
>
{link.filters?.length || 0} filter{(link.filters?.length || 0) !== 1 && "s"}
</Button>
<Button
variant="destructive"
size="sm"
className="h-6 w-6 p-0"
onClick={handleDelete}
disabled={isDeleting}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
};
// Check if link orders are equal
const areOrdersEqual = (links1: SortableLink[], links2: SortableLink[]) => {
if (links1.length !== links2.length) return false;
return links1.every((link, index) => link.id === links2[index].id);
};
// Main Advanced Links Component
export const AdvancedLinks = ({ channelId }: { channelId: string }) => {
const [links, setLinks] = useState<SortableLink[]>([]);
const [initialLinks, setInitialLinks] = useState<SortableLink[]>([]);
const [selectedLinkId, setSelectedLinkId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [linkMode] = useState("sequential"); // Could be made dynamic
const { toast } = useToast();
const hasOrderChanged = !areOrdersEqual(initialLinks, links);
const loadLinks = async () => {
try {
setLoading(true);
const response = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query GetLinks {
links {
id
shop {
id
name
}
filters
rank
createdAt
}
}
`
})
});
const data = await response.json();
console.log('Links response:', data);
if (data.data?.links) {
// Sort by rank if available, otherwise by creation date
const sortedLinks = data.data.links.sort((a: Link, b: Link) => {
if (a.rank !== undefined && b.rank !== undefined) {
return a.rank - b.rank;
}
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
setLinks(sortedLinks);
setInitialLinks(sortedLinks);
} else {
setError('No links data received');
}
} catch (err: any) {
console.error('Failed to load links:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLinks();
}, [channelId]);
const handleUpdateLink = async (linkId: string, data: any) => {
try {
const response = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
mutation UpdateLink($where: LinkWhereUniqueInput!, $data: LinkUpdateInput!) {
updateLink(where: $where, data: $data) {
id
shop {
id
name
}
filters
rank
}
}
`,
variables: {
where: { id: linkId },
data
}
})
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
loadLinks(); // Refresh the list
} catch (error) {
throw error;
}
};
const handleDeleteLink = async (linkId: string) => {
try {
const response = await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
mutation DeleteLink($where: LinkWhereUniqueInput!) {
deleteLink(where: $where) {
id
}
}
`,
variables: {
where: { id: linkId }
}
})
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors[0].message);
}
loadLinks(); // Refresh the list
} catch (error) {
throw error;
}
};
const handleSaveOrder = async () => {
setIsUpdating(true);
try {
const updatePromises = links.map((link, index) => {
return fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
mutation UpdateLink($where: LinkWhereUniqueInput!, $data: LinkUpdateInput!) {
updateLink(where: $where, data: $data) {
id
rank
}
}
`,
variables: {
where: { id: link.id },
data: { rank: index + 1 }
}
})
});
});
await Promise.all(updatePromises);
toast({
title: "Order Updated",
description: "Successfully updated link order",
});
loadLinks(); // Refresh to get updated ranks
} catch (error) {
toast({
title: "Error",
description: "Failed to update link order",
variant: "destructive",
});
loadLinks(); // Revert to server state
} finally {
setIsUpdating(false);
}
};
if (loading) {
return <div>Loading links...</div>;
}
if (error) {
return (
<div className="rounded-md border border-red-500/50 px-4 py-3 text-red-600">
<p className="text-sm">
<CircleAlert
className="me-3 -mt-0.5 inline-flex opacity-60"
size={16}
aria-hidden="true"
/>
Error loading links: {error}
</p>
</div>
);
}
const selectedLink = selectedLinkId ? links.find(l => l.id === selectedLinkId) : null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Links</h3>
<p className="text-sm text-muted-foreground">
Create links to shops based on filters
</p>
</div>
<div className="flex items-center gap-2">
{hasOrderChanged ? (
<Button
variant="outline"
size="sm"
onClick={handleSaveOrder}
disabled={isUpdating}
>
{isUpdating ? "Saving..." : "Save Order"}
</Button>
) : (
<CreateLinkButton channelId={channelId} refetch={loadLinks} />
)}
</div>
</div>
{links.length > 0 && (
<ReactSortable
list={links}
setList={setLinks}
handle=".cursor-grab"
className="space-y-2"
>
{links.map((link) => (
<LinkItem
key={link.id}
link={link}
linkMode={linkMode}
isSelected={selectedLinkId === link.id}
onSelect={() => setSelectedLinkId(link.id)}
onUpdate={handleUpdateLink}
onDelete={handleDeleteLink}
/>
))}
</ReactSortable>
)}
{links.length === 0 && (
<div className="text-center p-8 border border-dashed rounded-lg">
<p className="text-sm text-muted-foreground">
No links found. Create your first link above.
</p>
</div>
)}
{selectedLink && (
<div className="border rounded-lg p-4">
<div className="flex flex-col gap-3">
<div>
<h4 className="text-base font-medium">Filters for {selectedLink.shop.name}</h4>
<p className="text-xs text-muted-foreground">
Orders matching these filters will be processed by this shop
</p>
</div>
<FilterEditor
filters={selectedLink.filters || []}
onChange={(newFilters) => {
handleUpdateLink(selectedLink.id, { filters: newFilters });
}}
/>
</div>
</div>
)}
</div>
);
};
export default AdvancedLinks;
@@ -21,6 +21,7 @@ interface ChannelDetailsComponentProps {
loadingActions?: Record<string, Record<string, boolean>>;
removeEditItemButton?: boolean;
renderButtons?: () => React.ReactNode;
shops?: any[];
}
export const ChannelDetailsComponent = ({
@@ -28,6 +29,7 @@ export const ChannelDetailsComponent = ({
loadingActions = {},
removeEditItemButton,
renderButtons,
shops = [],
}: ChannelDetailsComponentProps) => {
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState(false);
@@ -137,6 +139,7 @@ export const ChannelDetailsComponent = ({
channel={channel}
open={isSettingsDrawerOpen}
onClose={() => setIsSettingsDrawerOpen(false)}
shops={shops}
/>
</>
);
@@ -29,13 +29,14 @@ interface Channel {
interface ChannelListClientProps {
channels: Channel[];
shops?: any[];
}
export function ChannelListClient({ channels }: ChannelListClientProps) {
export function ChannelListClient({ channels, shops = [] }: ChannelListClientProps) {
return (
<div className="relative grid gap-3 p-4">
{channels.map((channel: Channel) => (
<ChannelDetailsComponent key={channel.id} channel={channel as any} />
<ChannelDetailsComponent key={channel.id} channel={channel as any} shops={shops} />
))}
</div>
);
@@ -7,23 +7,26 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Ticket, SquareStack, ArrowRightLeft, Webhook } from 'lucide-react'
import { SearchOrders } from './SearchOrders'
import { AdvancedLinks } from './AdvancedLinks'
import { Links } from './Links'
import { MatchPageClient } from '../../matches/components/MatchPageClient'
import { Webhooks } from './Webhooks'
import { getChannelMatches } from '../../matches/actions/matches'
import { getListByPath } from '../../../dashboard/actions/getListByPath'
import type { Channel } from '../lib/types'
interface ChannelSettingsDrawerProps {
channel: Channel
open: boolean
onClose: () => void
shops?: any[]
}
export function ChannelSettingsDrawer({
channel,
open,
onClose
onClose,
shops = []
}: ChannelSettingsDrawerProps) {
const [matches, setMatches] = useState([])
const [matchesLoading, setMatchesLoading] = useState(false)
@@ -31,6 +34,7 @@ export function ChannelSettingsDrawer({
const itemsCount = channel.channelItems?.length || 0
const linksCount = channel.links?.length || 0
const matchesCount = matches?.length || 0
const [orderList, setOrderList] = useState<any>(null)
useEffect(() => {
if (open) {
@@ -45,6 +49,8 @@ export function ChannelSettingsDrawer({
.finally(() => {
setMatchesLoading(false)
})
getListByPath('orders').then(setOrderList)
}
}, [open, channel.id])
@@ -134,7 +140,7 @@ export function ChannelSettingsDrawer({
</TabsContent>
<TabsContent value="links" className="h-full bg-background p-4 md:p-6 border-t mt-0 overflow-auto">
<AdvancedLinks channelId={channel.id} />
<Links channelId={channel.id} channel={channel} shops={shops} orderList={orderList} />
</TabsContent>
<TabsContent value="webhooks" className="h-full bg-background p-4 md:p-6 border-t mt-0 overflow-auto">
+516 -198
View File
@@ -1,12 +1,11 @@
"use client";
'use client';
import React, { useState, useEffect } from "react";
import React, { useState, useMemo } from 'react';
import {
ArrowRight,
Edit2,
Plus,
Trash2,
CircleAlert,
GripVertical,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -18,263 +17,582 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverClose,
} from "@/components/ui/popover";
import { useToast } from "@/components/ui/use-toast";
import { useQueryClient } from '@tanstack/react-query';
import { enhanceFields } from '../../../dashboard/utils/enhanceFields';
import {
getShops,
getChannelLinks,
createChannelLink,
updateChannelLink,
deleteChannelLink
} from "../actions/links";
} from '../actions/links';
import { cn } from "@/lib/utils";
interface LinkFilter {
field: string;
type: string;
value: string;
}
interface LinksProps {
channelId: string;
channel: any;
shops?: any[];
orderList?: any;
}
interface Link {
id: string;
shop: {
shop?: {
id: string;
name: string;
};
filters: any;
createdAt: string;
filters?: LinkFilter[];
rank?: number;
}
interface Shop {
id: string;
name: string;
}
export const CreateLinkButton = ({ channelId, refetch }: {
// Create Link Button Component
const CreateLinkButton = ({ channelId, shops, onCreated }: {
channelId: string;
refetch: () => void;
shops: any[];
onCreated: () => void;
}) => {
const [shops, setShops] = useState<Shop[]>([]);
const [selectedShopId, setSelectedShopId] = useState<string>("");
const [isCreating, setIsCreating] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { toast } = useToast();
useEffect(() => {
const loadShops = async () => {
const response = await getShops();
if (response.success && response.data) {
setShops(response.data.shops);
}
};
loadShops();
}, []);
const handleCreate = async () => {
if (!selectedShopId) return;
const handleCreateLink = async (shopId: string) => {
setIsCreating(true);
try {
const response = await createChannelLink(channelId, selectedShopId);
const response = await createChannelLink(channelId, shopId, []);
if (response.success) {
toast({
title: "Link Created",
description: "Successfully created shop link",
});
setIsOpen(false);
setSelectedShopId("");
refetch();
toast({ title: "Link Created" });
onCreated();
} else {
toast({
title: "Error",
description: response.error || "Failed to create link",
variant: "destructive",
});
throw new Error(response.error || 'Failed to create link');
}
} catch (error) {
toast({
title: "Error",
description: "Failed to create link",
variant: "destructive",
});
} catch (error: any) {
toast({ title: "Error", description: error.message, variant: "destructive" });
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1">
<Plus className="h-3 w-3" />
Add Link
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground" disabled={isCreating}>
<Plus className="h-4 w-4" />
{isCreating ? "Creating..." : "Add Link"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Link</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Shop</label>
<Select value={selectedShopId} onValueChange={setSelectedShopId}>
<SelectTrigger>
<SelectValue placeholder="Select a shop..." />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Select Shop to Link</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{shops.length === 0 ? (
<DropdownMenuItem disabled>No shops available</DropdownMenuItem>
) : (
shops.map((shop) => (
<DropdownMenuItem
key={shop.id}
onClick={() => handleCreateLink(shop.id)}
disabled={isCreating}
>
{shop.name}
</DropdownMenuItem>
))
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Filter Chip Component - Tremor-inspired
const FilterChip = ({
filter,
index,
filterableFields,
enhancedFields,
onUpdate,
onRemove,
}: {
filter: LinkFilter;
index: number;
filterableFields: Record<string, any>;
enhancedFields: Record<string, any>;
onUpdate: (index: number, updates: Partial<LinkFilter>) => void;
onRemove: (index: number) => void;
}) => {
const [localType, setLocalType] = useState(filter.type);
const [localValue, setLocalValue] = useState(filter.value);
const getFilterTypes = (fieldPath: string) => {
const field = filterableFields[fieldPath];
if (!field?.controller?.filter?.types) return {};
return field.controller.filter.types;
};
const getFieldLabel = (fieldPath: string) => {
return enhancedFields[fieldPath]?.label || fieldPath;
};
const getTypeLabel = (fieldPath: string, typeKey: string) => {
const types = getFilterTypes(fieldPath);
return types[typeKey]?.label || typeKey;
};
const filterTypes = getFilterTypes(filter.field);
const hasValue = filter.value && filter.value.trim() !== '';
const fieldLabel = getFieldLabel(filter.field);
const typeLabel = getTypeLabel(filter.field, filter.type);
const handleApply = () => {
const updates: Partial<LinkFilter> = {};
if (localType !== filter.type) {
updates.type = localType;
}
if (localValue !== filter.value) {
updates.value = localValue;
}
if (Object.keys(updates).length > 0) {
onUpdate(index, updates);
}
};
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-x-1.5 whitespace-nowrap rounded-md border px-2 py-1.5 text-sm font-medium transition-colors",
"hover:bg-muted/50",
hasValue
? "border-border bg-background text-foreground"
: "border-dashed border-muted-foreground/50 text-muted-foreground"
)}
>
<span
aria-hidden="true"
onClick={(e) => {
if (hasValue) {
e.stopPropagation();
onRemove(index);
}
}}
className="flex items-center"
>
<Plus
className={cn(
"-ml-px h-4 w-4 shrink-0 transition-transform",
hasValue && "rotate-45 hover:text-destructive"
)}
/>
</span>
<span className="truncate max-w-[100px] font-medium">{fieldLabel}</span>
{hasValue && (
<>
<span className="text-muted-foreground/60" aria-hidden="true"></span>
<span className="truncate max-w-[80px] opacity-70">
{typeLabel}
</span>
<span className="text-muted-foreground/60" aria-hidden="true"></span>
<span className="truncate max-w-[100px] opacity-50">
{filter.value}
</span>
</>
)}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Field</label>
<div className="h-9 px-3 py-2 text-sm bg-muted rounded-md text-muted-foreground">
{getFieldLabel(filter.field)}
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Condition</label>
<Select value={localType} onValueChange={setLocalType}>
<SelectTrigger className="h-9 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{shops.map((shop) => (
<SelectItem key={shop.id} value={shop.id}>
{shop.name}
{Object.entries(filterTypes).map(([typeKey, typeConfig]: [string, any]) => (
<SelectItem key={typeKey} value={typeKey}>
{typeConfig.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<div className="space-y-1.5">
<label className="text-sm font-medium">Value</label>
<input
type="text"
placeholder="Enter value..."
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex gap-2 pt-1">
<PopoverClose asChild>
<Button
size="sm"
className="flex-1"
onClick={handleApply}
>
Apply
</Button>
</PopoverClose>
<Button
onClick={handleCreate}
disabled={!selectedShopId || isCreating}
size="sm"
variant="outline"
className="flex-1"
onClick={() => onRemove(index)}
>
{isCreating ? "Creating..." : "Create Link"}
Remove
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PopoverContent>
</Popover>
);
};
export function Links({ channelId }: { channelId: string }) {
const [links, setLinks] = useState<Link[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add Filter Button - Tremor-inspired dashed button
const AddFilterButton = ({
filterableFields,
enhancedFields,
onAdd,
disabled,
}: {
filterableFields: Record<string, any>;
enhancedFields: Record<string, any>;
onAdd: (filter: LinkFilter) => void;
disabled: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedField, setSelectedField] = useState('');
const [selectedType, setSelectedType] = useState('');
const [value, setValue] = useState('');
const fieldPaths = Object.keys(filterableFields);
const getFilterTypes = (fieldPath: string) => {
const field = filterableFields[fieldPath];
if (!field?.controller?.filter?.types) return {};
return field.controller.filter.types;
};
const getFieldLabel = (fieldPath: string) => {
return enhancedFields[fieldPath]?.label || fieldPath;
};
const handleFieldChange = (fieldPath: string) => {
setSelectedField(fieldPath);
const types = getFilterTypes(fieldPath);
const firstType = Object.keys(types)[0];
setSelectedType(firstType || '');
setValue('');
};
const handleApply = () => {
if (selectedField && selectedType) {
onAdd({ field: selectedField, type: selectedType, value });
setSelectedField('');
setSelectedType('');
setValue('');
setIsOpen(false);
}
};
const filterTypes = selectedField ? getFilterTypes(selectedField) : {};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"flex items-center gap-x-1.5 whitespace-nowrap rounded-md border border-dashed px-2 py-1.5 text-sm font-medium transition-colors",
"border-muted-foreground/50 text-muted-foreground hover:bg-muted/50 hover:text-foreground",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<Plus className="h-4 w-4 shrink-0" />
<span>Add Filter</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Field</label>
<Select value={selectedField} onValueChange={handleFieldChange}>
<SelectTrigger className="h-9 text-base">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{fieldPaths.map((fieldPath) => (
<SelectItem key={fieldPath} value={fieldPath}>
{getFieldLabel(fieldPath)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedField && (
<>
<div className="space-y-1.5">
<label className="text-sm font-medium">Condition</label>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="h-9 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(filterTypes).map(([typeKey, typeConfig]: [string, any]) => (
<SelectItem key={typeKey} value={typeKey}>
{typeConfig.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Value</label>
<input
type="text"
placeholder="Enter value..."
value={value}
onChange={(e) => setValue(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</>
)}
<div className="flex gap-2 pt-1">
<PopoverClose asChild>
<Button
size="sm"
className="flex-1"
onClick={handleApply}
disabled={!selectedField || !selectedType}
>
Add
</Button>
</PopoverClose>
<PopoverClose asChild>
<Button size="sm" variant="outline" className="flex-1">
Cancel
</Button>
</PopoverClose>
</div>
</div>
</PopoverContent>
</Popover>
);
};
// Individual Link Card Component
const LinkCard = ({
link,
linkMode = "sequential",
onDeleted,
orderList
}: {
link: Link;
linkMode?: string;
onDeleted: () => void;
orderList: any;
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const [localFilters, setLocalFilters] = useState<LinkFilter[]>(
Array.isArray(link.filters) ? link.filters : []
);
const { toast } = useToast();
const enhancedFields = useMemo(() => {
if (!orderList?.fields) return {};
return enhanceFields(orderList.fields, 'Order');
}, [orderList]);
const loadLinks = async () => {
try {
setIsLoading(true);
setError(null);
const response = await getChannelLinks(channelId);
if (response.success && response.data) {
setLinks(response.data.links);
} else {
setError(response.error || "Failed to load links");
const filterableFields = useMemo(() => {
const filtered: Record<string, any> = {};
Object.entries(enhancedFields).forEach(([path, field]: [string, any]) => {
if (field.controller?.filter) {
filtered[path] = field;
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load links");
} finally {
setIsLoading(false);
}
};
});
return filtered;
}, [enhancedFields]);
useEffect(() => {
loadLinks();
}, [channelId]);
const handleDelete = async (linkId: string) => {
const handleDelete = async () => {
setIsDeleting(true);
try {
const response = await deleteChannelLink(linkId);
const response = await deleteChannelLink(link.id);
if (response.success) {
toast({
title: "Link Deleted",
description: "Successfully deleted link",
});
loadLinks();
toast({ title: "Link Deleted" });
onDeleted();
} else {
toast({
title: "Error",
description: response.error || "Failed to delete link",
variant: "destructive",
});
throw new Error(response.error);
}
} catch (error) {
toast({
title: "Error",
description: "Failed to delete link",
variant: "destructive",
});
} catch (error: any) {
toast({ title: "Error", description: "Failed to delete link", variant: "destructive" });
} finally {
setIsDeleting(false);
}
};
if (error) {
return (
<div className="flex items-center gap-2 p-4 text-red-600">
<CircleAlert className="h-4 w-4" />
<span>{error}</span>
const saveFilters = async (newFilters: LinkFilter[]) => {
try {
const response = await updateChannelLink(link.id, { filters: newFilters });
if (!response.success) {
throw new Error(response.error);
}
} catch (error: any) {
toast({ title: "Error", description: "Failed to save filters", variant: "destructive" });
}
};
const addFilter = (filter: LinkFilter) => {
const newFilters = [...localFilters, filter];
setLocalFilters(newFilters);
saveFilters(newFilters);
};
const updateFilter = (index: number, updates: Partial<LinkFilter>) => {
const newFilters = [...localFilters];
newFilters[index] = { ...newFilters[index], ...updates };
setLocalFilters(newFilters);
saveFilters(newFilters);
};
const removeFilter = (index: number) => {
const newFilters = localFilters.filter((_, i) => i !== index);
setLocalFilters(newFilters);
saveFilters(newFilters);
};
return (
<div className="rounded-2xl bg-muted/50 p-1">
{/* Frame Header */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab shrink-0" />
<Badge variant="secondary" className="text-xs font-medium h-6 w-6 flex items-center justify-center p-0 rounded-md shrink-0">
{linkMode === "sequential" ? link.rank || 1 : "1"}
</Badge>
<h3 className="text-sm font-semibold truncate">{link.shop?.name || 'Unknown Shop'}</h3>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
{/* Frame Panel - Filter Chips */}
<div className="rounded-xl border bg-background p-4">
<div className="flex flex-wrap gap-2">
{localFilters.map((filter, index) => (
<FilterChip
key={index}
filter={filter}
index={index}
filterableFields={filterableFields}
enhancedFields={enhancedFields}
onUpdate={updateFilter}
onRemove={removeFilter}
/>
))}
<AddFilterButton
filterableFields={filterableFields}
enhancedFields={enhancedFields}
onAdd={addFilter}
disabled={!orderList || Object.keys(filterableFields).length === 0}
/>
</div>
{localFilters.length === 0 && (
<p className="text-xs text-muted-foreground mt-2">No filters - matches all orders</p>
)}
</div>
</div>
);
};
// Main Links Component
export const Links = ({ channelId, channel, shops = [], orderList }: LinksProps) => {
const queryClient = useQueryClient();
const links = channel?.links || [];
const invalidateChannels = async () => {
await queryClient.invalidateQueries({ queryKey: ['lists', 'Channel', 'items'] });
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Shop Links</h4>
<CreateLinkButton channelId={channelId} refetch={loadLinks} />
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
) : links.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No shop links configured.</p>
<p className="text-xs mt-1">
Links connect shops to this channel for order processing.
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">Links</h3>
<p className="text-sm text-muted-foreground">
Create links from shops based on filters
</p>
</div>
) : (
<div className="space-y-2">
{links.map((link) => (
<div
key={link.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-sm">{link.shop.name}</div>
<div className="text-xs text-muted-foreground">
Created {new Date(link.createdAt).toLocaleDateString()}
</div>
</div>
{Object.keys(link.filters || {}).length > 0 && (
<Badge variant="outline" className="text-xs">
Filtered
</Badge>
)}
</div>
<CreateLinkButton channelId={channelId} shops={shops} onCreated={invalidateChannels} />
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
// TODO: Implement filter editing
toast({
title: "Coming Soon",
description: "Filter editing will be available soon",
});
}}
>
<Edit2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
onClick={() => handleDelete(link.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{links.length > 0 ? (
<div className="space-y-3">
{links.map((link: Link) => (
<LinkCard
key={link.id}
link={link}
linkMode={channel?.linkMode || "sequential"}
onDeleted={invalidateChannels}
orderList={orderList}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed bg-muted/30 p-8 text-center">
<p className="text-sm text-muted-foreground">
No links found. Create your first link above.
</p>
</div>
)}
</div>
);
}
};
export default Links;
@@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import { useQueryClient } from '@tanstack/react-query';
import {
Tooltip,
TooltipContent,
@@ -201,14 +202,14 @@ const RecommendedWebhookItem = ({ webhook, onRefresh, channelId }: {
};
export const Webhooks = ({ channelId, channel }: { channelId: string; channel?: any }) => {
const [refreshKey, setRefreshKey] = useState(0);
const queryClient = useQueryClient();
const webhookData = channel?.webhooks;
const webhooks = webhookData?.data?.webhooks || [];
const recommendedWebhooks = webhookData?.recommendedWebhooks || [];
const loadWebhooks = () => {
setRefreshKey(prev => prev + 1);
const invalidateChannels = async () => {
await queryClient.invalidateQueries({ queryKey: ['lists', 'Channel', 'items'] });
};
if (!webhookData) {
@@ -240,7 +241,7 @@ export const Webhooks = ({ channelId, channel }: { channelId: string; channel?:
<WebhookItem
key={webhook.id}
webhook={webhook}
onRefresh={loadWebhooks}
onRefresh={invalidateChannels}
channelId={channelId}
/>
))}
@@ -262,7 +263,7 @@ export const Webhooks = ({ channelId, channel }: { channelId: string; channel?:
<RecommendedWebhookItem
key={`${webhook.topic}-${fullRecommendedUrl}`}
webhook={webhook}
onRefresh={loadWebhooks}
onRefresh={invalidateChannels}
channelId={channelId}
/>
) : null;
@@ -2,5 +2,5 @@
export { ChannelDetailsComponent } from './ChannelDetailsComponent';
// export { ChannelManagementDrawer } from './ChannelManagementDrawer';
export { SearchOrders } from './SearchOrders';
export { AdvancedLinks } from './AdvancedLinks';
export { Links } from './Links';
export { Webhooks } from './Webhooks';
@@ -1,5 +1,5 @@
import { getListByPath } from "@/features/dashboard/actions";
import { getFilteredChannelsWithPlatform, getChannelPlatforms } from "../actions";
import { getFilteredChannelsWithPlatform, getChannelPlatforms, getShops } from "../actions";
import { ChannelListPageClient } from "./ChannelListPageClient";
import { notFound } from 'next/navigation';
@@ -40,8 +40,8 @@ export async function ChannelListPage({ searchParams }: PageProps) {
}
}
// Fetch platforms and initial data for SSR
const [platformsResponse, channelsResponse] = await Promise.all([
// Fetch platforms, shops and initial data for SSR
const [platformsResponse, channelsResponse, shopsResponse] = await Promise.all([
getChannelPlatforms(),
getFilteredChannelsWithPlatform(
searchString || null,
@@ -49,11 +49,13 @@ export async function ChannelListPage({ searchParams }: PageProps) {
currentPage,
pageSize,
null
)
),
getShops()
]);
const platforms = platformsResponse.success ? (platformsResponse.data?.channelPlatforms || []) : []
const channels = channelsResponse.success ? channelsResponse.data?.items || [] : []
const shops = shopsResponse.success ? (shopsResponse.data?.shops || []) : []
const count = channelsResponse.success ? channelsResponse.data?.count || 0 : 0
const initialData = {
@@ -75,6 +77,7 @@ export async function ChannelListPage({ searchParams }: PageProps) {
}}
statusCounts={null}
platforms={platforms}
shops={shops}
searchParams={{
showCreateChannel: typeof resolvedSearchParams.showCreateChannel === "string" ? resolvedSearchParams.showCreateChannel : undefined,
platform: typeof resolvedSearchParams.platform === "string" ? resolvedSearchParams.platform : undefined,
@@ -52,6 +52,7 @@ interface ChannelListPageClientProps {
inactive: number
} | null
platforms?: any[]
shops?: any[]
searchParams?: any
}
@@ -62,6 +63,7 @@ export function ChannelListPageClient({
initialSearchParams,
statusCounts,
platforms = [],
shops = [],
searchParams: searchParamsFromServer = {}
}: ChannelListPageClientProps) {
const router = useRouter()
@@ -134,6 +136,9 @@ export function ChannelListPageClient({
}
links {
id
shop { id name }
filters
rank
}
`
@@ -279,7 +284,7 @@ export function ChannelListPageClient({
</Alert>
</div>
) : data && data.items && data.items.length > 0 ? (
<ChannelListClient channels={data.items} />
<ChannelListClient channels={data.items} shops={shops} />
) : (
<div className="flex items-center justify-center h-full py-10">
<div className="text-center">
+6
View File
@@ -38,6 +38,12 @@ export async function getShops(
}
links {
id
channel {
id
name
}
filters
rank
}
`
) {
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import { Webhooks } from './Webhooks'
import { OrderDetailsDialog } from './OrderDetailsDialog'
import { MatchPageClient } from '../../matches/components/MatchPageClient'
import { getShopMatches } from '../../matches/actions/matches'
import { getListByPath } from '../../../dashboard/actions/getListByPath'
import type { Shop } from '../lib/types'
interface ShopSettingsDrawerProps {
@@ -39,6 +40,7 @@ export function ShopSettingsDrawer({
const itemsCount = shop.shopItems?.length || 0
const linksCount = shop.links?.length || 0
const matchesCount = matches?.length || 0
const [orderList, setOrderList] = useState<any>(null)
useEffect(() => {
if (open) {
@@ -53,6 +55,8 @@ export function ShopSettingsDrawer({
.finally(() => {
setMatchesLoading(false)
})
getListByPath('orders').then(setOrderList)
}
}, [open, shop.id])
@@ -155,7 +159,7 @@ export function ShopSettingsDrawer({
</TabsContent>
<TabsContent value="links" className="h-full bg-background p-4 md:p-6 border-t mt-0 overflow-auto">
<Links shopId={shop.id} />
<Links shopId={shop.id} shop={shop} channels={channels} orderList={orderList} />
</TabsContent>
<TabsContent value="webhooks" className="h-full bg-background p-4 md:p-6 border-t mt-0 overflow-auto">
@@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import { useQueryClient } from '@tanstack/react-query';
import {
Tooltip,
TooltipContent,
@@ -200,14 +201,14 @@ const RecommendedWebhookItem = ({ webhook, onRefresh, shopId }: {
};
export const Webhooks = ({ shopId, shop }: { shopId: string; shop?: any }) => {
const [refreshKey, setRefreshKey] = useState(0);
const queryClient = useQueryClient();
const webhookData = shop?.webhooks;
const webhooks = webhookData?.data?.webhooks || [];
const recommendedWebhooks = webhookData?.recommendedWebhooks || [];
const loadWebhooks = () => {
setRefreshKey(prev => prev + 1);
const invalidateShops = async () => {
await queryClient.invalidateQueries({ queryKey: ['lists', 'Shop', 'items'] });
};
if (!webhookData) {
@@ -238,7 +239,7 @@ export const Webhooks = ({ shopId, shop }: { shopId: string; shop?: any }) => {
<WebhookItem
key={webhook.id}
webhook={webhook}
onRefresh={loadWebhooks}
onRefresh={invalidateShops}
shopId={shopId}
/>
))}
@@ -262,7 +263,7 @@ export const Webhooks = ({ shopId, shop }: { shopId: string; shop?: any }) => {
<RecommendedWebhookItem
key={`${webhook.topic}-${fullRecommendedUrl}`}
webhook={webhook}
onRefresh={loadWebhooks}
onRefresh={invalidateShops}
shopId={shopId}
/>
) : null;
@@ -142,6 +142,9 @@ export function ShopListPageClient({
}
links {
id
channel { id name }
filters
rank
}
`
+1020 -1089
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1921,7 +1921,7 @@ type Link {
rank: Int
filters: JSON
customWhere: JSON
dynamicWhereClause: String
dynamicWhereClause: JSON
shop: Shop
channel: Channel
user: User