Merge pull request 'fardeen-dev' (#25) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/25
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, RefreshCw, Save, Check, Plus, ArrowRight } from 'lucide-react';
|
||||
import { notify } from '../../../services/toast/index';
|
||||
|
||||
interface MappingRow {
|
||||
id: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
transformationType: string;
|
||||
defaultValue: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SHOPIFY_MAPPINGS: MappingRow[] = [
|
||||
{ id: 'm1', sourcePath: 'content.name', targetPath: 'title', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm2', sourcePath: 'content.description', targetPath: 'bodyHtml', transformationType: 'string', defaultValue: '', required: false },
|
||||
{ id: 'm3', sourcePath: 'content.status', targetPath: 'status', transformationType: 'uppercase', defaultValue: 'DRAFT', required: true },
|
||||
{ id: 'm4', sourcePath: 'taxonomy.brand.name', targetPath: 'vendor', transformationType: 'string', defaultValue: 'Generic', required: false },
|
||||
{ id: 'm5', sourcePath: 'taxonomy.category.name', targetPath: 'productType', transformationType: 'string', defaultValue: 'General', required: false },
|
||||
{ id: 'm6', sourcePath: 'variants.sku', targetPath: 'variants.sku', transformationType: 'string', defaultValue: '', required: true },
|
||||
{ id: 'm7', sourcePath: 'variants.price', targetPath: 'variants.price', transformationType: 'currency_format', defaultValue: '0.00', required: true }
|
||||
];
|
||||
|
||||
export default function FieldMappingsTab() {
|
||||
const [mappings, setMappings] = useState<MappingRow[]>(DEFAULT_SHOPIFY_MAPPINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
setSaving(true);
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
notify.success('Field mappings updated successfully!');
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleAddMapping = () => {
|
||||
const newId = `m_${Date.now()}`;
|
||||
setMappings([
|
||||
...mappings,
|
||||
{ id: newId, sourcePath: 'attributes.', targetPath: 'metafields.', transformationType: 'string', defaultValue: '', required: false }
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between bg-background p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Canonical PIM Attribute Mapping Schema</h3>
|
||||
<p className="text-xs text-muted-foreground">Map canonical product attributes to target channel GraphQL/REST properties</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMapping}
|
||||
className="px-3 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Add Mapping
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center gap-1 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />} Save Schema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-xl overflow-hidden bg-surface shadow-2xs">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-background border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-3 px-4">Canonical Source Path (PIM)</th>
|
||||
<th className="py-3 px-2 text-center">Transform</th>
|
||||
<th className="py-3 px-4">Channel Target Path (Shopify)</th>
|
||||
<th className="py-3 px-4">Transformation Type</th>
|
||||
<th className="py-3 px-4">Default Value</th>
|
||||
<th className="py-3 px-4 text-center">Required</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{mappings.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.sourcePath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, sourcePath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-2 text-center text-muted-foreground">
|
||||
<ArrowRight className="w-4 h-4 mx-auto text-primary" />
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.targetPath}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, targetPath: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2.5 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<select
|
||||
value={m.transformationType}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, transformationType: val } : p));
|
||||
}}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-xs text-foreground focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="string">string (direct)</option>
|
||||
<option value="uppercase">uppercase</option>
|
||||
<option value="lowercase">lowercase</option>
|
||||
<option value="currency_format">currency_format</option>
|
||||
<option value="json_stringify">json_stringify</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2.5 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.defaultValue}
|
||||
placeholder="—"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, defaultValue: val } : p));
|
||||
}}
|
||||
className="w-full font-mono text-[11px] bg-background border border-border rounded px-2 py-1 text-foreground focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.required}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setMappings(prev => prev.map(p => p.id === m.id ? { ...p, required: checked } : p));
|
||||
}}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface IntegrationHealthBadgeProps {
|
||||
status?: string;
|
||||
healthStatus?: string;
|
||||
}
|
||||
|
||||
export const IntegrationHealthBadge: React.FC<IntegrationHealthBadgeProps> = ({ status, healthStatus }) => {
|
||||
const normalizedStatus = (status || healthStatus || 'active').toLowerCase();
|
||||
|
||||
if (normalizedStatus === 'healthy' || normalizedStatus === 'active' || normalizedStatus === 'connected') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" />
|
||||
Healthy
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'syncing' || normalizedStatus === 'processing') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-600 animate-spin" />
|
||||
Syncing
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'degraded' || normalizedStatus === 'rate_limited' || normalizedStatus === 'pending') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
|
||||
{normalizedStatus === 'rate_limited' ? 'Rate Limited' : 'Pending'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-50 text-red-700 border border-red-200">
|
||||
<XCircle className="w-3.5 h-3.5 text-red-600" />
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart, ShoppingBag, Globe, Code2, Plus, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface IntegrationTemplateGalleryProps {
|
||||
onSelectShopify: () => void;
|
||||
onSelectCustomApi: () => void;
|
||||
}
|
||||
|
||||
export const IntegrationTemplateGallery: React.FC<IntegrationTemplateGalleryProps> = ({
|
||||
onSelectShopify,
|
||||
onSelectCustomApi
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary/5 via-surface to-emerald-500/5 border border-border rounded-xl p-6 mb-8 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||
Pre-Built Channel Templates
|
||||
<span className="text-[10px] font-extrabold uppercase bg-primary text-white px-2 py-0.5 rounded-full">
|
||||
Zero Config
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">Select a channel template to connect in 1 click using native GraphQL/REST capability adapters</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Shopify Template Card */}
|
||||
<div className="bg-surface border-2 border-emerald-500/30 hover:border-emerald-500 rounded-xl p-4 transition-all shadow-2xs group relative flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-center text-emerald-600 font-bold">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-emerald-700 bg-emerald-100 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-emerald-700 transition-colors">Shopify GraphQL</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Sync products, variants, assets & inventory via Shopify Admin API v2025-01 with cost bucket management.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectShopify}
|
||||
className="mt-4 w-full py-2 px-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Setup Shopify
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amazon Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">Amazon SP-API</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Syndicate ASIN listings, FBA inventory and pricing updates via Amazon Selling Partner API.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* WooCommerce Template Card */}
|
||||
<div className="bg-surface/60 border border-border rounded-xl p-4 transition-all shadow-2xs opacity-80 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 border border-purple-200 flex items-center justify-center text-purple-600 font-bold">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-muted-foreground bg-surface border border-border px-2 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm">WooCommerce REST</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Push PIM canonical catalog to WordPress WooCommerce stores via REST API v3.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border text-muted-foreground rounded-lg text-xs font-semibold cursor-not-allowed opacity-60 flex items-center justify-center gap-1"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom API Card */}
|
||||
<div className="bg-surface border border-border hover:border-primary/50 rounded-xl p-4 transition-all shadow-2xs group flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
||||
<Code2 className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||
Custom Wizard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-foreground text-sm group-hover:text-primary transition-colors">Custom API / Webhook</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
Configure multi-step generic REST/GraphQL endpoints with custom headers and transformations.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectCustomApi}
|
||||
className="mt-4 w-full py-2 px-3 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs flex items-center justify-center gap-1.5 cursor-pointer transition-colors"
|
||||
>
|
||||
Custom Wizard <ArrowRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, EyeOff, Key, Globe, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
|
||||
interface ShopifyCredentialCardProps {
|
||||
integrationId: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyCredentialCard: React.FC<ShopifyCredentialCardProps> = ({ integrationId, onSaved }) => {
|
||||
const [authMode, setAuthMode] = useState<'private_app' | 'custom_app'>('private_app');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
|
||||
// Private app fields
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [storefrontToken, setStorefrontToken] = useState('');
|
||||
|
||||
// Custom app token
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [fetchingCreds, setFetchingCreds] = useState(false);
|
||||
|
||||
const { setCredentials, testConnection, loading, testingConnection } = useIntegration();
|
||||
|
||||
// Pre-fill existing credentials for this specific integration
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
setFetchingCreds(true);
|
||||
integrationsService.getCredentials(integrationId)
|
||||
.then(creds => {
|
||||
if (creds.shop_domain) setShopDomain(creds.shop_domain);
|
||||
if (creds.api_key) setApiKey(creds.api_key);
|
||||
if (creds.api_secret_key) setApiSecret(creds.api_secret_key);
|
||||
if (creds.access_token) {
|
||||
setAccessToken(creds.access_token);
|
||||
if (creds.access_token.startsWith('shpat_')) {
|
||||
setAuthMode('custom_app');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setFetchingCreds(false));
|
||||
}, [integrationId]);
|
||||
|
||||
const handleSaveCredentials = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!shopDomain) return;
|
||||
|
||||
try {
|
||||
await setCredentials(integrationId, 'shop_domain', shopDomain.trim());
|
||||
|
||||
if (authMode === 'private_app') {
|
||||
if (apiKey) await setCredentials(integrationId, 'api_key', apiKey.trim());
|
||||
if (apiSecret) await setCredentials(integrationId, 'api_secret_key', apiSecret.trim());
|
||||
if (storefrontToken) await setCredentials(integrationId, 'access_token', storefrontToken.trim());
|
||||
} else {
|
||||
if (accessToken) await setCredentials(integrationId, 'access_token', accessToken.trim());
|
||||
}
|
||||
|
||||
if (onSaved) onSaved();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(integrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm flex items-center gap-2">
|
||||
Shopify Admin API Credentials
|
||||
{fetchingCreds && <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">Configure credentials for GraphQL product syndication</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary/10 text-primary rounded">GraphQL Admin 2025-01</span>
|
||||
</div>
|
||||
|
||||
{/* Auth Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Authentication Mode</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'private_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'private_app'} onChange={() => setAuthMode('private_app')} />
|
||||
Private App (API Key + Secret)
|
||||
</label>
|
||||
<label className={`p-2.5 border rounded-lg cursor-pointer text-xs transition-all ${authMode === 'custom_app' ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="credAuthMode" className="sr-only" checked={authMode === 'custom_app'} onChange={() => setAuthMode('custom_app')} />
|
||||
Custom App (shpat_ Token)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-4">
|
||||
{/* Shop Domain */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Store Domain <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="9xarg3-gj.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={(e) => setShopDomain(e.target.value.replace(/^https?:\/\//, '').replace(/\/$/, ''))}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Private App Fields */}
|
||||
{authMode === 'private_app' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef41669057976b331"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
API Secret Key (Password) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="API Secret / shpss_ token as password"
|
||||
value={apiSecret}
|
||||
onChange={(e) => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
For private apps: use the Shopify <strong>API secret key</strong> or <strong>shpss_ storefront token</strong> as password for Basic Auth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Storefront Token (Optional, shpss_...)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="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"
|
||||
value={storefrontToken}
|
||||
onChange={(e) => setStorefrontToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom App Token */}
|
||||
{authMode === 'custom_app' && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">
|
||||
Admin API Access Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pl-8 pr-10 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<Key className="w-4 h-4 text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">Stored using AES-256-GCM encryption</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult?.connected && (
|
||||
<div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg flex items-start gap-2 text-xs text-emerald-800 font-medium">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span>Connected to <strong>{testResult.shopName}</strong></span>
|
||||
{testResult.plan && <span className="ml-1 text-emerald-700">· {testResult.plan}</span>}
|
||||
{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-xs text-red-800">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<span>{testError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Save Credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="px-4 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-sm flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ShoppingCart, Zap, Key, Globe, Loader2, CheckCircle2, AlertCircle, Eye, EyeOff, ExternalLink, ArrowLeft } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
interface ShopifyTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const ShopifyTemplateModal: React.FC<ShopifyTemplateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [name, setName] = useState('Shopify Main Store');
|
||||
const [shopDomain, setShopDomain] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [apiSecret, setApiSecret] = useState('');
|
||||
const [syncMode, setSyncMode] = useState<'auto' | 'manual'>('manual');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const [savedIntegrationId, setSavedIntegrationId] = useState<string | null>(null);
|
||||
const [credentialsSaved, setCredentialsSaved] = useState(false);
|
||||
const [oauthConnecting, setOauthConnecting] = useState(false);
|
||||
const [oauthSuccess, setOauthSuccess] = useState(false);
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [savingCreds, setSavingCreds] = useState(false);
|
||||
|
||||
const { createItem, setCredentials, testConnection, testingConnection } = useIntegration();
|
||||
|
||||
// Handle OAuth callback redirect back from Shopify
|
||||
useEffect(() => {
|
||||
const oauthStatus = searchParams.get('oauth');
|
||||
const intId = searchParams.get('integrationId');
|
||||
const shop = searchParams.get('shop');
|
||||
if (oauthStatus === 'success' && intId) {
|
||||
setCredentialsSaved(true);
|
||||
setOauthSuccess(true);
|
||||
setSavedIntegrationId(intId);
|
||||
if (shop) setShopDomain(shop);
|
||||
onSuccess?.();
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSaveCredentials = async () => {
|
||||
if (!name || !shopDomain || !apiKey || !apiSecret) return;
|
||||
setSavingCreds(true);
|
||||
try {
|
||||
let cleanDomain = shopDomain.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let cleanKey = apiKey.trim();
|
||||
let cleanSecret = apiSecret.trim();
|
||||
|
||||
// Auto-correct if user accidentally swapped shop domain and API key
|
||||
if (cleanKey.includes('.myshopify.com') && !cleanDomain.includes('.myshopify.com')) {
|
||||
const temp = cleanDomain;
|
||||
cleanDomain = cleanKey;
|
||||
cleanKey = temp;
|
||||
setShopDomain(cleanDomain);
|
||||
setApiKey(cleanKey);
|
||||
}
|
||||
|
||||
if (cleanDomain && !cleanDomain.includes('.')) {
|
||||
cleanDomain = `${cleanDomain}.myshopify.com`;
|
||||
setShopDomain(cleanDomain);
|
||||
}
|
||||
|
||||
// Step 1: Create or reuse integration record
|
||||
let integrationId = savedIntegrationId;
|
||||
if (!integrationId) {
|
||||
const created = await createItem({
|
||||
name,
|
||||
channel: 'shopify',
|
||||
integration_type: 'ecommerce',
|
||||
sync_mode: syncMode,
|
||||
sync_frequency: syncMode === 'auto' ? 'realtime' : 'manual',
|
||||
status: 'pending'
|
||||
});
|
||||
integrationId = created.id;
|
||||
setSavedIntegrationId(integrationId);
|
||||
}
|
||||
|
||||
// Step 2: Store credentials encrypted
|
||||
await setCredentials(integrationId!, 'shop_domain', cleanDomain);
|
||||
await setCredentials(integrationId!, 'api_key', cleanKey);
|
||||
await setCredentials(integrationId!, 'api_secret_key', cleanSecret);
|
||||
|
||||
setCredentialsSaved(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSavingCreds(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setOauthConnecting(true);
|
||||
try {
|
||||
const result = await integrationsService.startShopifyOAuth(savedIntegrationId);
|
||||
// Open Shopify auth page in new tab
|
||||
window.open(result.authorizationUrl, '_blank', 'width=1000,height=700,scrollbars=yes');
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Failed to start OAuth');
|
||||
} finally {
|
||||
setOauthConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!savedIntegrationId) return;
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
try {
|
||||
const res = await testConnection(savedIntegrationId);
|
||||
setTestResult(res);
|
||||
} catch (err: any) {
|
||||
setTestError(err?.response?.data?.message || err?.message || 'Connection test failed');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('Shopify Main Store');
|
||||
setShopDomain('');
|
||||
setApiKey('');
|
||||
setApiSecret('');
|
||||
setSavedIntegrationId(null);
|
||||
setCredentialsSaved(false);
|
||||
setOauthSuccess(false);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
};
|
||||
|
||||
const step = !credentialsSaved ? 1 : !oauthSuccess ? 2 : 3;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 text-white relative">
|
||||
<button type="button" onClick={() => { onClose(); resetForm(); }} className="absolute top-4 right-4 text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 cursor-pointer">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
|
||||
<ShoppingCart className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Shopify Integration Setup</h2>
|
||||
<p className="text-xs text-white/70">Partners Dashboard OAuth 2.0 · Admin API 2025-01</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center border-b border-border px-6 pt-4 pb-3 gap-0">
|
||||
{[
|
||||
{ n: 1, label: 'Store Details & Keys' },
|
||||
{ n: 2, label: 'Authorize via OAuth' },
|
||||
{ n: 3, label: 'Test & Activate' }
|
||||
].map((s, i) => (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className={`flex items-center gap-1.5 ${step >= s.n ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold border-2 ${step > s.n ? 'bg-primary border-primary text-white' : step === s.n ? 'border-primary text-primary' : 'border-border text-muted-foreground'}`}>
|
||||
{step > s.n ? <CheckCircle2 className="w-3.5 h-3.5" /> : s.n}
|
||||
</div>
|
||||
<span className="text-xs font-medium hidden sm:block">{s.label}</span>
|
||||
</div>
|
||||
{i < 2 && <div className={`flex-1 h-px mx-3 ${step > s.n ? 'bg-primary' : 'bg-border'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto max-h-[65vh]">
|
||||
{/* Step 1: Store Details */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs text-blue-900">
|
||||
<p className="font-semibold mb-1">📍 Finding your Client ID & Client Secret:</p>
|
||||
<p>Go to <a href="https://partners.shopify.com" target="_blank" rel="noreferrer" className="underline font-bold">partners.shopify.com</a> → <strong>Apps</strong> → Select your app (<strong>PIM Integration</strong>) → <strong>App setup</strong> → Copy the <strong>Client ID</strong> and <strong>Client secret</strong> under <i>API credentials</i>.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Integration Name <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full text-sm bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary" placeholder="Shopify Main Store" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">Shop Domain <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<Globe className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="maskcomerce.myshopify.com"
|
||||
value={shopDomain}
|
||||
onChange={e => setShopDomain(e.target.value)}
|
||||
className="w-full text-sm font-mono bg-background border border-border rounded-lg px-3 py-2 pl-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Key (Client ID) <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6ed762d39bbeb6eef416..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1">API Secret (Client Secret) <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
placeholder="shpss_9dc647b3cd13de8..."
|
||||
value={apiSecret}
|
||||
onChange={e => setApiSecret(e.target.value)}
|
||||
className="w-full text-xs font-mono bg-background border border-border rounded-lg px-3 py-2 pr-9 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<button type="button" onClick={() => setShowSecret(v => !v)} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer">
|
||||
{showSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-2">Sync Mode</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['manual', 'auto'] as const).map(mode => (
|
||||
<label key={mode} className={`p-3 border rounded-lg cursor-pointer text-xs transition-all ${syncMode === mode ? 'border-primary bg-primary/5 text-primary font-bold' : 'border-border bg-background text-muted-foreground'}`}>
|
||||
<input type="radio" name="syncMode" className="sr-only" checked={syncMode === mode} onChange={() => setSyncMode(mode)} />
|
||||
{mode === 'manual' ? 'Manual Trigger' : 'Automatic (Outbox)'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCredentials}
|
||||
disabled={savingCreds || !shopDomain || !apiKey || !apiSecret || !name}
|
||||
className="w-full py-2.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{savingCreds && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Save & Continue to Authorize
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: OAuth Authorization */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-900">
|
||||
<p className="font-bold mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4" /> Allowed Redirection URLs in Partners Dashboard:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
||||
<li>Go to your <strong>Shopify Partners Dashboard</strong> → Apps → <strong>PIM Integration</strong> → App setup</li>
|
||||
<li>Under <strong>"Allowed redirection URL(s)"</strong>, add this URL:
|
||||
<code className="bg-amber-100 rounded px-1 py-0.5 text-[11px] font-mono block mt-1 font-bold">http://localhost:5002/api/v1/integrations/shopify/oauth/callback</code>
|
||||
<span className="text-[11px] text-amber-800 block mt-0.5">(If using port 5000, add <code>http://localhost:5000/api/v1/integrations/shopify/oauth/callback</code> as well)</span>
|
||||
</li>
|
||||
<li>Click <strong>Save</strong> in Partners Dashboard, then click Authorize below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-border rounded-xl p-4 space-y-2 text-xs">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Shop:</span><span className="font-mono font-semibold">{shopDomain}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">API Key:</span><span className="font-mono">{apiKey.slice(0, 12)}...</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Scopes:</span><span className="text-emerald-700">read/write_product_feeds, read/write_product_listings, read/write_products</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialsSaved(false)}
|
||||
className="px-4 py-3 bg-surface border border-border hover:bg-background text-foreground rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Edit Details & Keys
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartOAuth}
|
||||
disabled={oauthConnecting}
|
||||
className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-sm font-bold shadow-sm flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{oauthConnecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
|
||||
Authorize Shopify Access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After authorizing in the new tab, this modal will automatically update.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Connected — Test + Done */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center gap-3 text-emerald-800">
|
||||
<CheckCircle2 className="w-7 h-7 text-emerald-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-sm">Shopify Access Authorized!</p>
|
||||
<p className="text-xs mt-0.5">Access token saved securely. Your store is ready for product syndication.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testResult || testError) && (
|
||||
<div className={`p-3 rounded-lg flex items-start gap-2 text-xs font-medium border ${testResult?.connected ? 'bg-emerald-50 border-emerald-200 text-emerald-800' : 'bg-red-50 border-red-200 text-red-800'}`}>
|
||||
{testResult?.connected
|
||||
? <CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
}
|
||||
<div>
|
||||
{testResult?.connected
|
||||
? <><strong>{testResult.shopName}</strong>{testResult.plan && ` · ${testResult.plan}`}{testResult.email && <div className="text-[11px] mt-0.5">{testResult.email}</div>}</>
|
||||
: testError
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOauthSuccess(false); setCredentialsSaved(false); }}
|
||||
className="px-3 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Re-configure
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testingConnection}
|
||||
className="flex-1 py-2.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testingConnection ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5 text-amber-500" />}
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSuccess?.(); onClose(); resetForm(); }}
|
||||
className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Done — View Integrations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle2, AlertTriangle, Clock, RefreshCw, Layers } from 'lucide-react';
|
||||
import { useIntegration } from '../hook/useIntegration';
|
||||
import type { SyncItem } from '../types/integrations.types';
|
||||
|
||||
interface SyncJobStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
jobId: string;
|
||||
integrationName?: string;
|
||||
}
|
||||
|
||||
export const SyncJobStatusModal: React.FC<SyncJobStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
jobId,
|
||||
integrationName
|
||||
}) => {
|
||||
const [items, setItems] = useState<SyncItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { getSyncItems } = useIntegration();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && jobId) {
|
||||
setLoading(true);
|
||||
getSyncItems(jobId)
|
||||
.then(res => setItems(res))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, jobId, getSyncItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const total = items.length;
|
||||
const successCount = items.filter(i => i.status === 'success').length;
|
||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
||||
const pendingCount = items.filter(i => i.status === 'pending' || i.status === 'processing').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-3xl overflow-hidden flex flex-col max-h-[85vh] animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between bg-background/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground text-sm">Sync Execution Telemetry</h3>
|
||||
<p className="text-xs text-muted-foreground">{integrationName || 'Integration Sync Run'} • Job #{jobId.slice(0, 8)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-background rounded-lg text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div className="grid grid-cols-4 gap-3 p-4 bg-background border-b border-border text-center text-xs">
|
||||
<div className="p-2.5 bg-surface border border-border rounded-lg">
|
||||
<span className="text-muted-foreground font-semibold block">Total Scope</span>
|
||||
<span className="text-sm font-bold text-foreground">{total}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<span className="text-emerald-700 font-semibold block">Successful</span>
|
||||
<span className="text-sm font-bold text-emerald-800">{successCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-red-50 border border-red-200 rounded-lg">
|
||||
<span className="text-red-700 font-semibold block">Failed</span>
|
||||
<span className="text-sm font-bold text-red-800">{failedCount}</span>
|
||||
</div>
|
||||
<div className="p-2.5 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-blue-700 font-semibold block">In Progress</span>
|
||||
<span className="text-sm font-bold text-blue-800">{pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item table */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-xs text-muted-foreground gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-primary" />
|
||||
<span>Fetching telemetry items...</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-10 text-xs text-muted-foreground">
|
||||
No sync items logged for this job run yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground font-semibold">
|
||||
<th className="py-2 px-3">Product Name & SKU</th>
|
||||
<th className="py-2 px-3">Operation</th>
|
||||
<th className="py-2 px-3">Status</th>
|
||||
<th className="py-2 px-3 text-center">Attempts</th>
|
||||
<th className="py-2 px-3 text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-medium">
|
||||
{items.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-background/50 transition-colors">
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-bold text-foreground">{item.product?.name || `Product #${item.product_id.slice(0, 8)}`}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">{item.sku || item.product?.sku || item.product_id}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-bold bg-surface border border-border text-foreground">
|
||||
{item.operation}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{item.status === 'success' ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 font-bold text-[11px]">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> Success
|
||||
</span>
|
||||
) : item.status === 'failed' ? (
|
||||
<span className="inline-flex items-center gap-1 text-red-600 font-bold text-[11px]">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> Failed
|
||||
</span>
|
||||
) : item.status === 'skipped' ? (
|
||||
<span className="inline-flex items-center gap-1 text-amber-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5" /> Skipped
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-blue-600 font-bold text-[11px]">
|
||||
<Clock className="w-3.5 h-3.5 animate-spin" /> {item.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-mono text-center">{item.attempt_count}</td>
|
||||
<td className="py-2.5 px-3 text-right text-muted-foreground truncate max-w-[220px]" title={item.error_message || 'Synced'}>
|
||||
{item.error_message ? <span className="text-red-500 font-semibold">{item.error_message}</span> : <span className="text-emerald-600 font-semibold">Synced to Store</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border bg-background/50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +1,126 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { RefreshCw, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, Download, Layers } from "lucide-react";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { SyncJobStatusModal } from "./SyncJobStatusModal";
|
||||
import type { SyncJob } from "../types/integrations.types";
|
||||
|
||||
const MOCK_JOBS = [
|
||||
{
|
||||
id: "JOB-2891",
|
||||
integration: "Amazon India",
|
||||
type: "Full Sync",
|
||||
records: "4,240",
|
||||
success: "4,237",
|
||||
failed: "3",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 14:00",
|
||||
completed: "2025-06-09 14:32",
|
||||
duration: "32m 14s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Delta Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Running",
|
||||
started: "2025-06-09 14:30",
|
||||
completed: "",
|
||||
duration: "In progress",
|
||||
triggered: "Realtime trigger"
|
||||
},
|
||||
{
|
||||
id: "JOB-2890",
|
||||
integration: "Amazon UAE",
|
||||
type: "Full Sync",
|
||||
records: "1,840",
|
||||
success: "1,840",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 13:00",
|
||||
completed: "2025-06-09 13:15",
|
||||
duration: "15m 02s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2889",
|
||||
integration: "Warehouse WMS",
|
||||
type: "Inventory Pull",
|
||||
records: "284",
|
||||
success: "0",
|
||||
failed: "284",
|
||||
status: "Failed",
|
||||
started: "2025-06-08 08:00",
|
||||
completed: "2025-06-08 08:03",
|
||||
duration: "3m 12s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2888",
|
||||
integration: "Retail POS Network",
|
||||
type: "Catalogue Sync",
|
||||
records: "3,240",
|
||||
success: "3,240",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2025-06-09 12:00",
|
||||
completed: "2025-06-09 12:18",
|
||||
duration: "18m 40s",
|
||||
triggered: "Scheduled"
|
||||
},
|
||||
{
|
||||
id: "JOB-2887",
|
||||
integration: "Amazon India",
|
||||
type: "Price Update",
|
||||
records: "521",
|
||||
success: "521",
|
||||
failed: "0",
|
||||
status: "Cancelled",
|
||||
started: "2025-06-08 18:00",
|
||||
completed: "2025-06-08 18:01",
|
||||
duration: "1m 04s",
|
||||
triggered: "Manual"
|
||||
},
|
||||
{
|
||||
id: "JOB-2892",
|
||||
integration: "Shopify Main Store",
|
||||
type: "Realtime Sync",
|
||||
records: "128",
|
||||
success: "128",
|
||||
failed: "0",
|
||||
status: "Completed",
|
||||
started: "2026-08-30 04:30",
|
||||
completed: "2026-08-30 04:31",
|
||||
duration: "1m 02s",
|
||||
triggered: "Outbox Trigger"
|
||||
}
|
||||
];
|
||||
|
||||
export default function SyncJobsList() {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed") variant = "success";
|
||||
if (val === "Running") variant = "warning";
|
||||
if (val === "Failed") variant = "error";
|
||||
if (val === "Cancelled") variant = "neutral";
|
||||
const { getAllSyncJobs } = useIntegration();
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
const fetchJobs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await getAllSyncJobs();
|
||||
const mapped = list.map((j: any) => ({
|
||||
id: j.id,
|
||||
integration: j.integration?.name || 'Shopify Store',
|
||||
type: j.trigger_source === 'outbox' ? 'Realtime Outbox' : 'Manual Trigger',
|
||||
records: String(j.total_items || 0),
|
||||
success: String(j.success_items || 0),
|
||||
failed: String(j.failed_items || 0),
|
||||
status: j.status === 'completed' ? 'Completed' : j.status === 'failed' ? 'Failed' : 'Running',
|
||||
started: j.started_at ? new Date(j.started_at).toLocaleString() : '—',
|
||||
completed: j.completed_at ? new Date(j.completed_at).toLocaleString() : '—',
|
||||
duration: j.completed_at && j.started_at
|
||||
? `${Math.round((new Date(j.completed_at).getTime() - new Date(j.started_at).getTime()) / 1000)}s`
|
||||
: 'In progress',
|
||||
triggered: j.trigger_source || 'manual'
|
||||
}));
|
||||
setJobs(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sync jobs:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
}, []);
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={MOCK_JOBS}
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-surface">
|
||||
<option>All Statuses</option>
|
||||
<option>Completed</option>
|
||||
<option>Running</option>
|
||||
<option>Failed</option>
|
||||
</select>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
toolbarRight={
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Log
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
const columns = [
|
||||
{ key: "id", label: "JOB ID", render: (val: string) => <span className="font-mono text-primary font-medium">{val.slice(0, 8)}</span> },
|
||||
{ key: "integration", label: "INTEGRATION" },
|
||||
{ key: "type", label: "JOB TYPE" },
|
||||
{
|
||||
key: "records",
|
||||
label: "RECORDS",
|
||||
render: (_: any, row: any) => (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-foreground">{row.records}</span>
|
||||
{row.failed && parseInt(row.failed) > 0 && (
|
||||
<span className="text-red-600 text-xs ml-1">({row.failed} failed)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "STATUS",
|
||||
render: (val: string) => {
|
||||
let variant: BadgeVariant = "neutral";
|
||||
if (val === "Completed" || val === "completed") variant = "success";
|
||||
if (val === "Running" || val === "pending" || val === "processing") variant = "warning";
|
||||
if (val === "Failed" || val === "failed") variant = "error";
|
||||
|
||||
return <StatusBadge status={variant} label={val} />;
|
||||
},
|
||||
},
|
||||
{ key: "started", label: "STARTED AT" },
|
||||
{ key: "completed", label: "COMPLETED AT" },
|
||||
{ key: "duration", label: "DURATION" },
|
||||
{ key: "triggered", label: "TRIGGERED BY" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={jobs}
|
||||
actionConfig={{
|
||||
onView: (row) => setSelectedJobId(row.id),
|
||||
}}
|
||||
searchPlaceholder="Search jobs..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchJobs} className="flex items-center gap-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh Jobs
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedJobId && (
|
||||
<SyncJobStatusModal
|
||||
isOpen={!!selectedJobId}
|
||||
onClose={() => setSelectedJobId(null)}
|
||||
jobId={selectedJobId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import { notify } from '../../../services/toast';
|
||||
|
||||
export const useIntegration = () => {
|
||||
const [items, setItems] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [triggeringSync, setTriggeringSync] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -63,5 +72,91 @@ export const useIntegration = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
const testConnection = useCallback(async (id: string): Promise<TestConnectionResult> => {
|
||||
setTestingConnection(true);
|
||||
try {
|
||||
const res = await integrationsService.testConnection(id);
|
||||
if (res.connected) {
|
||||
notify.success(`Connected to Shopify store: ${res.shopName || res.shopDomain}`);
|
||||
}
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
notify.error(err?.message || 'Failed to connect to Shopify store');
|
||||
throw err;
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setCredentials = useCallback(async (id: string, type: string, value: string, expiresAt?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await integrationsService.setCredentials(id, type, value, expiresAt);
|
||||
notify.success('Credentials configured securely!');
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerSync = useCallback(async (id: string, options?: { productId?: string; productIds?: string[] }) => {
|
||||
setTriggeringSync(true);
|
||||
try {
|
||||
const res = await integrationsService.triggerSync(id, options);
|
||||
notify.success(`Sync initialized for ${res?.totalItems || 1} product(s)!`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setTriggeringSync(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncJobs = useCallback(async (id: string): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncJobs(id);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getAllSyncJobs = useCallback(async (): Promise<SyncJob[]> => {
|
||||
try {
|
||||
return await integrationsService.getAllSyncJobs();
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSyncItems = useCallback(async (jobId: string): Promise<SyncItem[]> => {
|
||||
try {
|
||||
return await integrationsService.getSyncItems(jobId);
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
testingConnection,
|
||||
triggeringSync,
|
||||
fetchItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
testConnection,
|
||||
setCredentials,
|
||||
triggerSync,
|
||||
getSyncJobs,
|
||||
getAllSyncJobs,
|
||||
getSyncItems
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
Code2,
|
||||
Monitor,
|
||||
Warehouse,
|
||||
Globe
|
||||
Globe,
|
||||
Zap,
|
||||
Play,
|
||||
Key,
|
||||
X,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { useIntegration } from "../hook/useIntegration";
|
||||
import { useChannel } from "../../channels/hook/useChannel";
|
||||
@@ -24,15 +28,17 @@ import type { Integration } from "../types/integrations.types";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
|
||||
// Import Tab Components
|
||||
// Import Custom Feature Components & Pre-Built Templates
|
||||
import { IntegrationHealthBadge } from "../components/IntegrationHealthBadge";
|
||||
import { ShopifyCredentialCard } from "../components/ShopifyCredentialCard";
|
||||
import { IntegrationTemplateGallery } from "../components/IntegrationTemplateGallery";
|
||||
import { ShopifyTemplateModal } from "../components/ShopifyTemplateModal";
|
||||
import FieldMappingsList from "../components/FieldMappingsTab";
|
||||
import PublishingRulesList from "../components/PublishingRulesTab";
|
||||
import SyncJobsList from "../components/SyncJobsTab";
|
||||
import ErrorCenterList from "../components/ErrorCenterTab";
|
||||
import AuditLogsList from "../components/AuditLogsTab";
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
|
||||
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
|
||||
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
|
||||
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
@@ -47,14 +53,23 @@ const INTEGRATION_META: Record<string, { label: string; icon: any; color: string
|
||||
|
||||
export default function IntegrationList() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<"Connections" | "Field Mappings" | "Publishing Rules" | "Sync Jobs" | "Error Center" | "Audit & Logs">("Connections");
|
||||
const [statusFilter, setStatusFilter] = useState("All Status");
|
||||
const [typeFilter, setTypeFilter] = useState("All Types");
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
|
||||
const [shopifyModalOpen, setShopifyModalOpen] = useState(false);
|
||||
const [credentialModal, setCredentialModal] = useState<{ isOpen: boolean; integrationId: string; name: string }>({
|
||||
isOpen: false,
|
||||
integrationId: "",
|
||||
name: ""
|
||||
});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||
|
||||
const { items, fetchItems, loading, deleteItem } = useIntegration();
|
||||
const { items, fetchItems, loading, deleteItem, testConnection, triggerSync } = useIntegration();
|
||||
const { items: channels, fetchItems: fetchChannels } = useChannel();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +77,13 @@ export default function IntegrationList() {
|
||||
fetchChannels();
|
||||
}, [fetchItems, fetchChannels]);
|
||||
|
||||
// Automatically open Shopify modal into Step 3 when returning from OAuth redirect
|
||||
useEffect(() => {
|
||||
if (searchParams.get('oauth') === 'success') {
|
||||
setShopifyModalOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteModal.id) return;
|
||||
setIsDeleting(true);
|
||||
@@ -75,13 +97,37 @@ export default function IntegrationList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnectionClick = async (id: string) => {
|
||||
setTestingId(id);
|
||||
try {
|
||||
await testConnection(id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerSyncClick = async (id: string) => {
|
||||
setSyncingId(id);
|
||||
try {
|
||||
await triggerSync(id);
|
||||
setActiveTab("Sync Jobs");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Integration Name",
|
||||
sortable: true,
|
||||
render: (_: any, row: Integration) => {
|
||||
const meta = INTEGRATION_META[row.integrationType] || INTEGRATION_META.custom_api;
|
||||
const metaType = row.integrationType || 'ecommerce';
|
||||
const meta = INTEGRATION_META[metaType] || INTEGRATION_META.ecommerce;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -90,7 +136,7 @@ export default function IntegrationList() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{row.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.description || "No description provided"}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{row.channel ? `Channel: ${row.channel.toUpperCase()}` : "No description provided"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -101,61 +147,77 @@ export default function IntegrationList() {
|
||||
label: "Channel",
|
||||
render: (val: string) => {
|
||||
const chan = channels.find(c => c.code === val || c.id === val);
|
||||
return <span className="font-medium">{chan ? chan.name : val}</span>;
|
||||
return <span className="font-semibold text-xs uppercase px-2 py-0.5 rounded bg-surface border border-border">{chan ? chan.name : val || 'Shopify'}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "integrationType",
|
||||
label: "Type",
|
||||
render: (val: string) => {
|
||||
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
|
||||
return <span className="text-xs font-medium text-muted-foreground capitalize">{meta.label}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "environment",
|
||||
label: "Environment",
|
||||
render: (val: string) => <span className="text-blue-600 text-sm font-medium capitalize">{val}</span>,
|
||||
key: "sync_mode",
|
||||
label: "Sync Mode",
|
||||
render: (_: any, row: Integration) => (
|
||||
<span className="text-xs font-mono capitalize text-muted-foreground">{row.sync_mode || row.syncMode || 'auto'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Connection Status",
|
||||
render: (val: string) => {
|
||||
const isSuccess = val === "Connected" || val === "active";
|
||||
const isWarning = val === "Pending" || val === "pending";
|
||||
label: "Health Status",
|
||||
render: (_: any, row: Integration) => (
|
||||
<IntegrationHealthBadge status={row.status} healthStatus={row.health_status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "last_synced_at",
|
||||
label: "Last Synced",
|
||||
render: (_: any, row: Integration) => {
|
||||
const ts = row.last_synced_at || row.lastSync;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={isSuccess ? "success" : isWarning ? "warning" : "neutral"}
|
||||
label={val === "active" ? "Connected" : val === "pending" ? "Pending" : val}
|
||||
/>
|
||||
<div className="text-xs text-foreground font-medium">
|
||||
{ts ? new Date(ts).toLocaleString() : 'Not synced yet'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "lastSync",
|
||||
label: "Last Sync",
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground font-medium text-sm">{row.lastSync || "—"}</div>
|
||||
{row.syncErrors && row.syncErrors > 0 && (
|
||||
<div className="text-red-600 text-xs mt-0.5">{row.syncErrors} failed</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestConnectionClick(row.id)}
|
||||
disabled={testingId === row.id}
|
||||
title="Test Connection"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-amber-600 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Zap className={`w-3.5 h-3.5 ${testingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTriggerSyncClick(row.id)}
|
||||
disabled={syncingId === row.id}
|
||||
title="Trigger Manual Sync"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-primary cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Play className={`w-3.5 h-3.5 ${syncingId === row.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: true, integrationId: row.id, name: row.name })}
|
||||
title="Configure Credentials"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-background text-foreground cursor-pointer"
|
||||
>
|
||||
<Key className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
|
||||
title="Delete Integration"
|
||||
className="p-1.5 rounded-lg border border-border bg-surface hover:bg-red-50 hover:border-red-200 text-red-600 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "published", label: "Published", render: (val: any) => val || 0 },
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Created By",
|
||||
render: (_: any, row: Integration) => (
|
||||
<div>
|
||||
<div className="text-foreground text-sm font-medium">{row.author || "Admin"}</div>
|
||||
<div className="text-muted-foreground text-xs mt-0.5">
|
||||
{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const filteredItems = items.filter(item => {
|
||||
@@ -165,7 +227,7 @@ export default function IntegrationList() {
|
||||
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
|
||||
|
||||
const matchesType = typeFilter === "All Types" ||
|
||||
typeFilter.toLowerCase() === item.integrationType.toLowerCase();
|
||||
(item.integrationType && typeFilter.toLowerCase() === item.integrationType.toLowerCase());
|
||||
|
||||
return matchesStatus && matchesType;
|
||||
});
|
||||
@@ -173,142 +235,165 @@ export default function IntegrationList() {
|
||||
return (
|
||||
<ProtectedRoute node="settings.integrations">
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />New Integration
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Integration Hub" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" className="bg-surface border-border text-foreground hover:bg-background" onClick={fetchItems}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setShopifyModalOpen(true)} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />Setup Shopify
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All integrations"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
{/* Quick Pre-Built Template Gallery */}
|
||||
<IntegrationTemplateGallery
|
||||
onSelectShopify={() => setShopifyModalOpen(true)}
|
||||
onSelectCustomApi={() => navigate("new")}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Connected Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active").length}
|
||||
subtitle="Healthy"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Failed Sync Jobs"
|
||||
value={items.filter(i => i.syncErrors && i.syncErrors > 0).length}
|
||||
subtitle="Need attention"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="red"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Last Synchronised"
|
||||
value="14:32"
|
||||
subtitle="Today"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Publishing Rules", count: 5 },
|
||||
{ id: "Sync Jobs", count: 6 },
|
||||
{ id: "Error Center", count: items.filter(i => i.syncErrors && i.syncErrors > 0).length },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Integrations"
|
||||
value={items.length}
|
||||
subtitle="All active channels"
|
||||
icon={<Plug className="w-5 h-5" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Healthy Systems"
|
||||
value={items.filter(i => i.status === "Connected" || i.status === "active" || i.health_status === 'healthy').length}
|
||||
subtitle="Operational"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Outbox Events"
|
||||
value="Active"
|
||||
subtitle="Realtime Outbox Queue"
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Engine Health"
|
||||
value="BullMQ"
|
||||
subtitle="Redis Workers Running"
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
color="slate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
statusKey="status"
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option>All Types</option>
|
||||
<option>Marketplace</option>
|
||||
<option>E-Commerce</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border mt-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-6 pt-2 overflow-x-auto">
|
||||
{[
|
||||
{ id: "Connections", count: items.length },
|
||||
{ id: "Field Mappings", count: 7 },
|
||||
{ id: "Publishing Rules", count: 1 },
|
||||
{ id: "Sync Jobs", count: null },
|
||||
{ id: "Error Center", count: 0 },
|
||||
{ id: "Audit & Logs", count: null }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer shrink-0 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{tab.id}
|
||||
{tab.count !== null && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
activeTab === tab.id ? 'bg-primary-light text-primary-dark' : 'bg-surface-muted text-muted-foreground'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === "Connections" && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredItems}
|
||||
rowIdKey="id"
|
||||
resultLabel="integrations"
|
||||
actionConfig={{
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
searchPlaceholder="Search integrations..."
|
||||
toolbarLeft={
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="h-9 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground bg-surface"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option>All Status</option>
|
||||
<option>Connected</option>
|
||||
<option>Pending</option>
|
||||
<option>Disconnected</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "Field Mappings" && <FieldMappingsList />}
|
||||
{activeTab === "Publishing Rules" && <PublishingRulesList />}
|
||||
{activeTab === "Sync Jobs" && <SyncJobsList />}
|
||||
{activeTab === "Error Center" && <ErrorCenterList />}
|
||||
{activeTab === "Audit & Logs" && <AuditLogsList />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
{/* Pre-Built Shopify Template Modal */}
|
||||
<ShopifyTemplateModal
|
||||
isOpen={shopifyModalOpen}
|
||||
onClose={() => setShopifyModalOpen(false)}
|
||||
onSuccess={fetchItems}
|
||||
/>
|
||||
|
||||
{/* Credentials Configuration Modal */}
|
||||
{credentialModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
className="absolute top-3 right-3 p-1 text-muted-foreground hover:text-foreground cursor-pointer z-10"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<ShopifyCredentialCard
|
||||
integrationId={credentialModal.integrationId}
|
||||
onSaved={() => setCredentialModal({ isOpen: false, integrationId: "", name: "" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title="Delete Integration"
|
||||
description="Are you sure you want to delete this integration? This action cannot be undone."
|
||||
itemName={deleteModal.name}
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import type {
|
||||
Integration,
|
||||
IntegrationCreateRequest,
|
||||
IntegrationUpdateRequest,
|
||||
TestConnectionResult,
|
||||
SyncJob,
|
||||
SyncItem
|
||||
} from '../types/integrations.types';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
@@ -10,22 +17,82 @@ interface ApiResponse<T> {
|
||||
export const integrationsService = {
|
||||
getAll: async (): Promise<Integration[]> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
|
||||
return res.data || [];
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as Integration[];
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Integration | undefined> => {
|
||||
const res = await apiClient.get<ApiResponse<Integration>>(`/api/v1/integrations/${id}`);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
create: async (req: IntegrationCreateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
update: async (id: string, req: IntegrationUpdateRequest): Promise<Integration> => {
|
||||
const res = await apiClient.put<ApiResponse<Integration>>(`/api/v1/integrations/${id}`, req);
|
||||
return res.data;
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Integration;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/integrations/${id}`);
|
||||
return res.success;
|
||||
return res.success || true;
|
||||
},
|
||||
|
||||
getCredentials: async (id: string): Promise<Record<string, string>> => {
|
||||
const res = await apiClient.get<ApiResponse<Record<string, string>>>(`/api/v1/integrations/${id}/credentials`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as Record<string, string>;
|
||||
},
|
||||
|
||||
setCredentials: async (id: string, credentialType: string, secretValue: string, expiresAt?: string): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/credentials`, {
|
||||
credential_type: credentialType,
|
||||
secret_value: secretValue,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
testConnection: async (id: string): Promise<TestConnectionResult> => {
|
||||
const res = await apiClient.post<ApiResponse<TestConnectionResult>>(`/api/v1/integrations/${id}/test-connection`);
|
||||
const raw: any = res.data;
|
||||
return (raw?.data || raw) as TestConnectionResult;
|
||||
},
|
||||
|
||||
triggerSync: async (id: string, options?: { productId?: string; productIds?: string[] }): Promise<any> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/sync`, options || {});
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
},
|
||||
|
||||
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/${id}/jobs`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getAllSyncJobs: async (): Promise<SyncJob[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncJob[]>>(`/api/v1/integrations/jobs/all`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncJob[];
|
||||
},
|
||||
|
||||
getSyncItems: async (jobId: string): Promise<SyncItem[]> => {
|
||||
const res = await apiClient.get<ApiResponse<SyncItem[]>>(`/api/v1/integrations/jobs/${jobId}/items`);
|
||||
const raw: any = res.data;
|
||||
return (Array.isArray(raw) ? raw : raw?.data || []) as SyncItem[];
|
||||
},
|
||||
|
||||
startShopifyOAuth: async (id: string): Promise<{ authorizationUrl: string; state: string }> => {
|
||||
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/integrations/${id}/shopify/oauth/start`);
|
||||
const raw: any = res.data;
|
||||
return raw?.data || raw;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,62 +2,97 @@ export interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
channel: string; // channel code or ID
|
||||
integrationType: string; // e.g. ecommerce, marketplace, erp, wms, pos, b2b_portal, mobile_app, website, custom_api
|
||||
environment: string; // e.g. production, staging, development
|
||||
status: string; // Connected, Pending, Disconnected, active, inactive, pending
|
||||
channel: string; // e.g. shopify, amazon, custom_api
|
||||
integrationType?: string; // e.g. ecommerce, marketplace, erp
|
||||
environment?: string;
|
||||
status: string; // active, inactive, pending, error
|
||||
health_status?: string; // healthy, degraded, error
|
||||
healthStatus?: string;
|
||||
sync_mode?: string; // auto, manual, scheduled
|
||||
syncMode?: string;
|
||||
sync_frequency?: string; // realtime, hourly, daily
|
||||
syncFrequency?: string;
|
||||
last_synced_at?: string;
|
||||
lastSync?: string;
|
||||
|
||||
// E-commerce/Website connection config
|
||||
// E-commerce connection credentials & details
|
||||
storeUrl?: string;
|
||||
accessToken?: string;
|
||||
apiVersion?: string;
|
||||
webhookSecret?: string;
|
||||
shopIdentifier?: string;
|
||||
shopDomain?: string;
|
||||
|
||||
// Marketplace specific fields
|
||||
sellerId?: string;
|
||||
marketplaceId?: string;
|
||||
awsAccessKeyId?: string;
|
||||
awsSecretKey?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
// ERP/WMS specific fields
|
||||
authMethod?: string;
|
||||
authToken?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
// POS specific fields
|
||||
posTerminalId?: string;
|
||||
posStoreCode?: string;
|
||||
posApiKey?: string;
|
||||
posApiSecret?: string;
|
||||
|
||||
// Mobile App specific fields
|
||||
appId?: string;
|
||||
bundleIdentifier?: string;
|
||||
gatewayUrl?: string;
|
||||
|
||||
// Custom API specific fields
|
||||
customApiUrl?: string;
|
||||
customApiHeaderKey?: string;
|
||||
customApiHeaderValue?: string;
|
||||
|
||||
// Sync settings
|
||||
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
|
||||
syncFrequency: string; // manual, hourly, daily, realtime
|
||||
autoRetry: boolean;
|
||||
retryAttempts: number;
|
||||
|
||||
// Listing page read-only / metadata fields
|
||||
lastSync?: string;
|
||||
syncErrors?: number;
|
||||
published?: string | number;
|
||||
author?: string;
|
||||
createdAt: string;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt'>;
|
||||
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt' | 'created_at'>;
|
||||
export type IntegrationUpdateRequest = Partial<IntegrationCreateRequest>;
|
||||
|
||||
export interface TestConnectionResult {
|
||||
connected: boolean;
|
||||
shopName?: string;
|
||||
shopDomain?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: string;
|
||||
tenant_id: number;
|
||||
integration_id: string;
|
||||
trigger_source: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
total_items: number;
|
||||
success_items: number;
|
||||
failed_items: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SyncAttempt {
|
||||
id: string;
|
||||
sync_item_id: string;
|
||||
attempt_number: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
status: string;
|
||||
request_method: string;
|
||||
request_url: string;
|
||||
response_status?: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SyncErrorItem {
|
||||
id: string;
|
||||
error_code: string;
|
||||
error_type: string;
|
||||
message: string;
|
||||
provider_message?: string;
|
||||
http_status?: number;
|
||||
retryable: boolean;
|
||||
attempt_number: number;
|
||||
}
|
||||
|
||||
export interface SyncItem {
|
||||
id: string;
|
||||
sync_job_id: string;
|
||||
integration_id: string;
|
||||
product_id: string;
|
||||
variant_id?: string;
|
||||
sku?: string;
|
||||
operation: string;
|
||||
status: 'pending' | 'processing' | 'success' | 'failed' | 'skipped';
|
||||
source_version: number;
|
||||
idempotency_key: string;
|
||||
attempt_count: number;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
attempts?: SyncAttempt[];
|
||||
errors?: SyncErrorItem[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user