diff --git a/src/features/channels/api/channels.api.ts b/src/features/channels/api/channels.api.ts index f8a52b3..ec8f291 100644 --- a/src/features/channels/api/channels.api.ts +++ b/src/features/channels/api/channels.api.ts @@ -34,6 +34,26 @@ export const channelsApi = { const res = await apiClient.delete>(`${BASE_URL}/${id}`); return res.success; }, + + getMappings: async (channelId: string): Promise => { + const res = await apiClient.get>(`${BASE_URL}/${channelId}/mappings`); + return res.data || []; + }, + + updateMappings: async (channelId: string, mappings: any[]): Promise => { + const res = await apiClient.put>(`${BASE_URL}/${channelId}/mappings`, { mappings }); + return res.data; + }, + + triggerSyndication: async (channelId: string): Promise => { + const res = await apiClient.post>(`${BASE_URL}/${channelId}/syndicate`); + return res.data; + }, + + getJobs: async (channelId: string): Promise => { + const res = await apiClient.get>(`${BASE_URL}/${channelId}/jobs`); + return res.data || []; + }, }; export default channelsApi; diff --git a/src/features/channels/components/ChannelMappingTab.tsx b/src/features/channels/components/ChannelMappingTab.tsx new file mode 100644 index 0000000..c707ad2 --- /dev/null +++ b/src/features/channels/components/ChannelMappingTab.tsx @@ -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([]); + 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 ( +
+
+
+ ); + } + + return ( +
+
+
+

Channel Field Mapping Matrix

+

Map central PIM attributes to target storefront fields and apply transformation pipelines.

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + {mappings.map((rule, idx) => ( + + + + + + + + + ))} + +
PIM Central AttributePipelineTarget Storefront FieldTransformation RuleRequiredAction
+ + + + + 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" + /> + + + + handleChange(idx, "is_required", e.target.checked)} + className="rounded border-border text-primary focus:ring-primary h-4 w-4" + /> + + +
+
+
+ ); +} diff --git a/src/features/channels/components/SyndicationHistoryTab.tsx b/src/features/channels/components/SyndicationHistoryTab.tsx new file mode 100644 index 0000000..28396f8 --- /dev/null +++ b/src/features/channels/components/SyndicationHistoryTab.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [selectedErrorLog, setSelectedErrorLog] = useState(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 Completed; + case 'failed': + return Failed; + case 'running': + return Running; + default: + return Pending; + } + }; + + return ( +
+
+
+

Syndication Execution History

+

Real-time execution runs, success metrics, and error log inspection for this channel.

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + {jobs.length === 0 ? ( + + + + ) : ( + jobs.map((job) => ( + + + + + + + + + + )) + )} + +
Job IDStatusTotal ProductsSuccessFailedStarted AtLog Details
+ No syndication runs recorded yet. Click "Trigger Instant Sync" to start your first job run. +
{job.id.slice(0, 8)}...{getStatusBadge(job.status)}{job.total_products}{job.success_count}{job.failed_count}{new Date(job.started_at || job.created_at).toLocaleString()} + {job.error_log && job.error_log.length > 0 ? ( + + ) : ( + + )} +
+
+ + {/* Error Log Inspection Modal */} + {selectedErrorLog && ( +
+
+
+

+ Syndication Error Logs +

+ +
+
+ {selectedErrorLog.map((err, idx) => ( +
+
Product SKU: {err.sku || 'N/A'} (ID: {err.productId})
+
{err.error}
+
+ ))} +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/src/features/channels/pages/NewChannel.tsx b/src/features/channels/pages/NewChannel.tsx index 05b9b37..13e53f5 100644 --- a/src/features/channels/pages/NewChannel.tsx +++ b/src/features/channels/pages/NewChannel.tsx @@ -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() {
)} - {/* Step 3 — Summary */} + {/* Step 3 — Field Mapping Matrix */} + {activeStep === "mapping" && ( +
+ +
+ )} + + {/* Step 4 — Syndication History */} + {activeStep === "syndication" && ( +
+ +
+ )} + + {/* Step 5 — Summary */} {activeStep === "summary" && (