feat(channels): implement ChannelMappingTab, SyndicationHistoryTab and integrate multi-step syndication wizard
This commit is contained in:
@@ -34,6 +34,26 @@ export const channelsApi = {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
|
||||
return res.success;
|
||||
},
|
||||
|
||||
getMappings: async (channelId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/mappings`);
|
||||
return res.data || [];
|
||||
},
|
||||
|
||||
updateMappings: async (channelId: string, mappings: any[]): Promise<any> => {
|
||||
const res = await apiClient.put<ApiResponse<any>>(`${BASE_URL}/${channelId}/mappings`, { mappings });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
triggerSyndication: async (channelId: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/syndicate`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getJobs: async (channelId: string): Promise<any[]> => {
|
||||
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/${channelId}/jobs`);
|
||||
return res.data || [];
|
||||
},
|
||||
};
|
||||
|
||||
export default channelsApi;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, Plus, Trash2, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { channelsApi } from "../api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
const COMMON_PIM_ATTRIBUTES = [
|
||||
{ code: "name", label: "Product Title / Name" },
|
||||
{ code: "sku", label: "SKU / Item Code" },
|
||||
{ code: "description", label: "Product Description" },
|
||||
{ code: "price", label: "Retail Price" },
|
||||
{ code: "brand", label: "Brand / Manufacturer" },
|
||||
{ code: "status", label: "Publication Status" },
|
||||
{ code: "created_at", label: "Creation Timestamp" },
|
||||
];
|
||||
|
||||
const COMMON_CHANNEL_FIELDS = [
|
||||
{ code: "title", label: "Storefront Title (title)" },
|
||||
{ code: "body_html", label: "HTML Body Description (body_html)" },
|
||||
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
|
||||
{ code: "price", label: "Variant Price (price)" },
|
||||
{ code: "vendor", label: "Brand / Vendor (vendor)" },
|
||||
{ code: "product_type", label: "Product Category / Type (product_type)" },
|
||||
];
|
||||
|
||||
const TRANSFORMATION_RULES = [
|
||||
{ value: "none", label: "Direct Pass-through" },
|
||||
{ value: "uppercase", label: "UPPERCASE" },
|
||||
{ value: "lowercase", label: "lowercase" },
|
||||
{ value: "currency_format", label: "Currency Format (0.00)" },
|
||||
{ value: "strip_html", label: "Strip HTML Tags" },
|
||||
{ value: "default_if_null", label: "Fallback Default Value" },
|
||||
];
|
||||
|
||||
export function ChannelMappingTab({ channelId }: { channelId: string }) {
|
||||
const [mappings, setMappings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMappings();
|
||||
}, [channelId]);
|
||||
|
||||
const loadMappings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await channelsApi.getMappings(channelId);
|
||||
if (data && data.length > 0) {
|
||||
setMappings(data);
|
||||
} else {
|
||||
// Default initial baseline mappings
|
||||
setMappings([
|
||||
{ pim_attribute_code: "name", channel_field_code: "title", transformation_rule: "none", default_value: "", is_required: true },
|
||||
{ pim_attribute_code: "sku", channel_field_code: "variant_sku", transformation_rule: "uppercase", default_value: "", is_required: true },
|
||||
{ pim_attribute_code: "price", channel_field_code: "price", transformation_rule: "currency_format", default_value: "0.00", is_required: true },
|
||||
{ pim_attribute_code: "brand", channel_field_code: "vendor", transformation_rule: "none", default_value: "Generic", is_required: false },
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to load channel mapping rules");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
setMappings((prev) => [
|
||||
...prev,
|
||||
{ pim_attribute_code: "name", channel_field_code: "custom_field", transformation_rule: "none", default_value: "", is_required: false },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveRule = (index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, field: string, value: any) => {
|
||||
setMappings((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await channelsApi.updateMappings(channelId, mappings);
|
||||
notify.success("Attribute mapping rules saved successfully!");
|
||||
await loadMappings();
|
||||
} catch {
|
||||
notify.error("Failed to save mapping rules");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Channel Field Mapping Matrix</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Map central PIM attributes to target storefront fields and apply transformation pipelines.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleAddRule}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Rule
|
||||
</Button>
|
||||
<Button variant="primary" loading={saving} onClick={handleSave}>
|
||||
<Save className="w-4 h-4 mr-2" /> Save Mappings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">PIM Central Attribute</th>
|
||||
<th className="px-4 py-3 text-center">Pipeline</th>
|
||||
<th className="px-4 py-3">Target Storefront Field</th>
|
||||
<th className="px-4 py-3">Transformation Rule</th>
|
||||
<th className="px-4 py-3 text-center">Required</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{mappings.map((rule, idx) => (
|
||||
<tr key={idx} className="hover:bg-surface-muted/50">
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={rule.pim_attribute_code}
|
||||
onChange={(e) => handleChange(idx, "pim_attribute_code", e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{COMMON_PIM_ATTRIBUTES.map((attr) => (
|
||||
<option key={attr.code} value={attr.code}>
|
||||
{attr.label} ({attr.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<ArrowRight className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={rule.channel_field_code}
|
||||
onChange={(e) => handleChange(idx, "channel_field_code", e.target.value)}
|
||||
placeholder="e.g. title or body_html"
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs font-mono focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={rule.transformation_rule}
|
||||
onChange={(e) => handleChange(idx, "transformation_rule", e.target.value)}
|
||||
className="w-full px-3 py-1.5 border border-border rounded-md bg-surface text-foreground text-xs focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{TRANSFORMATION_RULES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(rule.is_required)}
|
||||
onChange={(e) => handleChange(idx, "is_required", e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-primary h-4 w-4"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(idx)}
|
||||
className="p-1.5 text-danger hover:bg-danger/10 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Play, RefreshCw, AlertCircle, CheckCircle2, Clock, Eye } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { channelsApi } from "../api/channels.api";
|
||||
import { notify } from "../../../services/toast";
|
||||
|
||||
export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [selectedErrorLog, setSelectedErrorLog] = useState<any[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, [channelId]);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await channelsApi.getJobs(channelId);
|
||||
setJobs(data || []);
|
||||
} catch {
|
||||
notify.error("Failed to load syndication history");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await channelsApi.triggerSyndication(channelId);
|
||||
notify.success("Syndication job triggered and processed successfully!");
|
||||
await loadJobs();
|
||||
} catch {
|
||||
notify.error("Failed to trigger syndication job");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-success/10 text-success border border-success/20"><CheckCircle2 className="w-3.5 h-3.5" /> Completed</span>;
|
||||
case 'failed':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
|
||||
case 'running':
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">Syndication Execution History</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Real-time execution runs, success metrics, and error log inspection for this channel.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadJobs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</Button>
|
||||
<Button variant="primary" loading={syncing} onClick={handleTriggerSync}>
|
||||
<Play className="w-4 h-4 mr-2" /> Trigger Instant Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface shadow-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-surface-muted border-b border-border text-xs text-muted-foreground uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Job ID</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3 text-center">Total Products</th>
|
||||
<th className="px-4 py-3 text-center">Success</th>
|
||||
<th className="px-4 py-3 text-center">Failed</th>
|
||||
<th className="px-4 py-3">Started At</th>
|
||||
<th className="px-4 py-3 text-right">Log Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground text-xs">
|
||||
No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-surface-muted/50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
|
||||
<td className="px-4 py-3 text-center font-medium">{job.total_products}</td>
|
||||
<td className="px-4 py-3 text-center text-success font-semibold">{job.success_count}</td>
|
||||
<td className="px-4 py-3 text-center text-danger font-semibold">{job.failed_count}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(job.started_at || job.created_at).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{job.error_log && job.error_log.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedErrorLog(job.error_log)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-danger bg-danger/10 hover:bg-danger/20 rounded transition-colors"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" /> View Errors ({job.error_log.length})
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Error Log Inspection Modal */}
|
||||
{selectedErrorLog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl max-w-2xl w-full p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h4 className="text-base font-semibold text-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5" /> Syndication Error Logs
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setSelectedErrorLog(null)}
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-bold"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{selectedErrorLog.map((err, idx) => (
|
||||
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
|
||||
<div className="font-semibold text-danger">Product SKU: {err.sku || 'N/A'} (ID: {err.productId})</div>
|
||||
<div className="text-muted-foreground mt-1">{err.error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="outline" onClick={() => setSelectedErrorLog(null)}>
|
||||
Close Inspector
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,10 +28,15 @@ const CHANNEL_TYPES = [
|
||||
{ id: "website", label: "Website", icon: Monitor, color: "text-primary", bg: "bg-primary/5" },
|
||||
];
|
||||
|
||||
import { ChannelMappingTab } from "../components/ChannelMappingTab";
|
||||
import { SyndicationHistoryTab } from "../components/SyndicationHistoryTab";
|
||||
|
||||
const STEPS = [
|
||||
{ id: "basic", label: "Basic Information", step: 1 },
|
||||
{ id: "availability",label: "Availability", step: 2 },
|
||||
{ id: "summary", label: "Summary", step: 3 },
|
||||
{ id: "basic", label: "Basic Information", step: 1 },
|
||||
{ id: "availability", label: "Availability", step: 2 },
|
||||
{ id: "mapping", label: "Field Mapping Matrix", step: 3 },
|
||||
{ id: "syndication", label: "Syndication History", step: 4 },
|
||||
{ id: "summary", label: "Summary", step: 5 },
|
||||
];
|
||||
|
||||
const channelSchema = Yup.object({
|
||||
@@ -330,7 +335,21 @@ export default function NewChannel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Summary */}
|
||||
{/* Step 3 — Field Mapping Matrix */}
|
||||
{activeStep === "mapping" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
|
||||
<ChannelMappingTab channelId={id || "demo-channel-id"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Syndication History */}
|
||||
{activeStep === "syndication" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden p-6">
|
||||
<SyndicationHistoryTab channelId={id || "demo-channel-id"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5 — Summary */}
|
||||
{activeStep === "summary" && (
|
||||
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
|
||||
<CardHeader title="Summary" subtitle="Review your channel configuration before saving" />
|
||||
|
||||
Reference in New Issue
Block a user