feat(channels): implement ChannelMappingTab, SyndicationHistoryTab and integrate multi-step syndication wizard

This commit is contained in:
Inamul-hasan-tec
2026-08-12 13:23:08 +05:30
parent b560fc650b
commit e75efac2dc
4 changed files with 397 additions and 4 deletions
+20
View File
@@ -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>
);
}
+23 -4
View File
@@ -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" />