mirror of
https://github.com/openshiporg/openship.git
synced 2026-06-19 07:35:55 +00:00
refactor: overhaul link system with dynamic filters and unified Links component
This commit is contained in:
+95
-12
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
Generated
+1020
-1089
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1921,7 +1921,7 @@ type Link {
|
||||
rank: Int
|
||||
filters: JSON
|
||||
customWhere: JSON
|
||||
dynamicWhereClause: String
|
||||
dynamicWhereClause: JSON
|
||||
shop: Shop
|
||||
channel: Channel
|
||||
user: User
|
||||
|
||||
Reference in New Issue
Block a user