19 Commits
Author SHA1 Message Date
Inamul-hasan-tec 1d6e96d5af Merge branch 'origin/dev' into feature/inam-platform-core-setup 2026-09-03 13:20:48 +05:30
mahir 3da12a9e49 Merge pull request 'Fix product and product family configuration' (#26) from mahir-fixes into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/26
2026-09-01 10:12:24 +00:00
Mahir-Mohamed 1d9f7840cd Fix product and product family configuration 2026-09-01 14:59:28 +05:30
Inamul-hasan-tec b16018cdd0 fix(rbac): gate PIM navigation and notification calls 2026-09-01 12:45:32 +05:30
Inamul-hasan-tec 4401683197 fix(sso): deduplicate one-time grant exchange 2026-09-01 11:22:02 +05:30
fardeen 65d2f48dce Merge pull request 'fardeen-dev' (#25) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/25
2026-08-31 14:04:37 +00:00
Mohammed-Fardeen-02 9980a67261 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-31 19:33:22 +05:30
Mohammed-Fardeen-02 9e05f162a9 implemented shopify integration 2026-08-31 19:33:00 +05:30
Inamul-hasan-tec cc9807b70d fix(integrations): clarify support mode API access 2026-08-31 18:13:04 +05:30
Inamul-hasan-tec 0f8e7cebc1 fix(auth): isolate SSO styles and repair login layout 2026-08-31 17:05:35 +05:30
Inamul-hasan-tec 136365a05e feat(platform): add tenant-aware channels integrations and SaaS SSO UI 2026-08-31 16:07:14 +05:30
fardeen 064b1726da Merge pull request 'fardeen-dev' (#24) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/24
2026-08-29 11:29:01 +00:00
Mohammed-Fardeen-02 2bb10e43dc Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-29 16:58:25 +05:30
Mohammed-Fardeen-02 2f1524d0fa resolved the assets filter 2026-08-29 16:57:53 +05:30
fardeen 4b70e8dbf8 Merge pull request 'fardeen-dev' (#23) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/23
2026-08-29 10:02:29 +00:00
Mohammed-Fardeen-02 eb3a613cf2 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend into fardeen-dev 2026-08-29 15:31:49 +05:30
Mohammed-Fardeen-02 ed816920e2 resolved the variant and the assets issue 2026-08-29 15:30:58 +05:30
fardeen 3c15c4313f Merge pull request 'implemented product and variant creation' (#22) from fardeen-dev into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_frontend/pulls/22
2026-08-27 11:16:10 +00:00
Mohammed-Fardeen-02 06bafc288c implemented product and variant creation 2026-08-27 16:44:20 +05:30
85 changed files with 5336 additions and 2191 deletions
+6
View File
@@ -0,0 +1,6 @@
# Public browser configuration. Do not place SSO module secrets here.
VITE_API_URL=http://localhost:5002
# Use / for a dedicated PIM hostname, or /pim_frontend/ when hosted below
# https://id-ms.maskantech.in/pim_frontend/.
VITE_BASE_PATH=/
+2 -1
View File
@@ -31,7 +31,8 @@ axiosInstance.interceptors.response.use(
(response) => response,
(error: { config?: { url?: string }; response?: { status?: number } }) => {
const isLoginEndpoint = error.config?.url?.includes('/auth/login');
if (error.response?.status === 401 && !isLoginEndpoint) {
const isSsoExchangeEndpoint = error.config?.url?.includes('/auth/sso/exchange');
if (error.response?.status === 401 && !isLoginEndpoint && !isSsoExchangeEndpoint) {
localStorage.removeItem('accessToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
@@ -1,6 +1,6 @@
// src/authentication/components/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAppSelector } from '../../store';
import { usePermissions } from '../../hooks/usePermission';
import { AccessDenied } from '../../components/customs/AccessDenied';
@@ -47,4 +47,20 @@ export const PlatformGuard: React.FC<{ children: React.ReactNode }> = ({ childre
return <>{children}</>;
};
/**
* Prevents Global SaaS administrators from opening tenant operational modules.
* A platform administrator must explicitly enter audited Support Mode first.
*/
export const TenantWorkspaceGuard: React.FC = () => {
const { user } = useAppSelector((state) => state.auth);
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const isSupportMode = Boolean(localStorage.getItem('impersonatedTenantId'));
if (isPlatformUser && !isSupportMode) {
return <Navigate to="/platform/overview" replace />;
}
return <Outlet />;
};
export default AuthGuard;
+20 -2
View File
@@ -7,6 +7,24 @@ import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
import { notify } from "../../services/toast";
const GoogleIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path fill="#4285F4" d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.4a4.6 4.6 0 0 1-2 3v2.5h3.3c1.9-1.8 2.9-4.4 2.9-7.4Z" />
<path fill="#34A853" d="M12 22c2.7 0 5-.9 6.7-2.4l-3.3-2.5c-.9.6-2.1 1-3.4 1a5.9 5.9 0 0 1-5.5-4.1H3.1v2.6A10 10 0 0 0 12 22Z" />
<path fill="#FBBC05" d="M6.5 14a6 6 0 0 1 0-3.9V7.4H3.1a10 10 0 0 0 0 9.2L6.5 14Z" />
<path fill="#EA4335" d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.8A9.7 9.7 0 0 0 3.1 7.4l3.4 2.7A5.9 5.9 0 0 1 12 5.9Z" />
</svg>
);
const MicrosoftIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path fill="#F25022" d="M2 2h9.5v9.5H2z" />
<path fill="#7FBA00" d="M12.5 2H22v9.5h-9.5z" />
<path fill="#00A4EF" d="M2 12.5h9.5V22H2z" />
<path fill="#FFB900" d="M12.5 12.5H22V22h-9.5z" />
</svg>
);
export const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -175,11 +193,11 @@ export const Login = () => {
<div className="mt-4 grid grid-cols-2 gap-3">
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475656/google-color.svg" alt="Google" />
<GoogleIcon />
<span className="ml-2">Google</span>
</button>
<button className="w-full inline-flex justify-center py-2 px-4 border border-border rounded-xl shadow-sm bg-surface text-sm font-medium text-muted-foreground hover:bg-background transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475662/microsoft.svg" alt="Microsoft" />
<MicrosoftIcon />
<span className="ml-2">Microsoft</span>
</button>
</div>
+73 -39
View File
@@ -1,55 +1,89 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Package } from 'lucide-react';
import './Login.css';
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react';
import { useDispatch } from 'react-redux';
import { authService } from '../services/authService';
import { setCredentials } from '../../store/slices/authSlice';
import { AuthLayout } from '../components/AuthLayout';
type SSOExchangeResponse = {
data?: { user: unknown; accessToken: string; refreshToken?: string; permissions?: Record<string, unknown> };
user?: unknown;
accessToken?: string;
refreshToken?: string;
permissions?: Record<string, unknown>;
};
let activeGrant: string | null = null;
let activeExchange: Promise<unknown> | null = null;
function exchangeGrantOnce(grant: string) {
if (activeGrant !== grant || !activeExchange) {
activeGrant = grant;
activeExchange = authService.exchangeSaaSToken(grant);
}
return activeExchange;
}
export const SSOCallback = () => {
const [searchParams] = useSearchParams();
const initialError = searchParams.get('error');
const initialGrant = searchParams.get('grant');
const validInitialGrant = Boolean(initialGrant && /^[a-f0-9]{32}$/i.test(initialGrant));
const navigate = useNavigate();
const [error, setError] = useState<string>('');
const [status, setStatus] = useState<string>('Processing SSO login...');
const dispatch = useDispatch();
const [error, setError] = useState<string>(() => initialError
? `SSO Error: ${initialError}`
: validInitialGrant ? '' : 'The SSO link is missing, invalid, or already used. Please open PIM again from the SaaS portal.');
const [status, setStatus] = useState<string>(() => initialError || !validInitialGrant ? 'SSO login failed' : 'Processing SSO login...');
useEffect(() => {
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`SSO Error: ${errorParam}`);
const grant = searchParams.get('grant');
const callbackPath = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/sso/callback`;
window.history.replaceState({}, document.title, callbackPath);
if (searchParams.get('error') || !grant || !/^[a-f0-9]{32}$/i.test(grant)) return;
let cancelled = false;
exchangeGrantOnce(grant).then((response) => {
if (cancelled) return;
const result = response as SSOExchangeResponse;
const session = result.data ?? result;
const { user, accessToken, refreshToken, permissions } = session;
if (!accessToken) throw new Error('PIM session token was not returned');
localStorage.removeItem('impersonatedTenantId');
localStorage.setItem('accessToken', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
dispatch(setCredentials({ user, accessToken, refreshToken, permissions: permissions ?? {} }));
const tenantName = (user as { tenant?: { name?: string } } | undefined)?.tenant?.name;
setStatus(`Welcome to ${tenantName || 'PIM'}. Redirecting...`);
navigate('/dashboard', { replace: true });
}).catch((requestError: unknown) => {
if (cancelled) return;
const message = (requestError as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(message || 'SSO login failed. Please open PIM again from the SaaS portal.');
setStatus('SSO login failed');
setTimeout(() => navigate('/login'), 3000);
return;
}
setStatus('Login successful! Redirecting...');
setTimeout(() => navigate('/dashboard'), 1000);
}, [searchParams, navigate]);
});
return () => { cancelled = true; };
}, [searchParams, navigate, dispatch]);
return (
<div className="login-container">
<div className="login-background" />
<div className="login-right-panel" style={{ width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<div className="login-card">
<div className="login-card-header" style={{ textAlign: 'center' }}>
<div className="login-logo-badge" style={{ margin: '0 auto 1rem' }}>
<Package className="login-logo-icon" />
</div>
<h2 className="login-card-title">SSO Authentication</h2>
<p className="login-card-subtitle">{status}</p>
</div>
{error ? (
<div className="error-message" style={{ marginTop: '1rem' }}>
{error}
</div>
) : (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{
border: '3px solid #f3f3f3', borderTop: '3px solid #3498db',
borderRadius: '50%', width: '40px', height: '40px',
animation: 'spin 1s linear infinite', margin: '0 auto'
}} />
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
)}
<AuthLayout>
<div className="flex flex-col items-center text-center py-8">
<div className={`w-14 h-14 rounded-full flex items-center justify-center mb-4 ${error ? 'bg-danger/10' : 'bg-primary/10'}`}>
{error
? <AlertCircle className="w-7 h-7 text-danger" />
: <ShieldCheck className="w-7 h-7 text-primary" />}
</div>
<h2 className="text-xl font-bold text-foreground">Secure SaaS Sign-in</h2>
<p className="text-sm text-muted-foreground mt-2">{status}</p>
{error ? (
<div className="w-full mt-6 p-4 rounded-xl bg-danger/10 border border-danger/20 text-danger text-sm text-left">
{error}
</div>
) : (
<Loader2 className="w-8 h-8 text-primary animate-spin mt-8" aria-label="Completing secure sign-in" />
)}
</div>
</div>
</AuthLayout>
);
};
+3 -2
View File
@@ -40,8 +40,9 @@ export const authService = {
return response;
},
exchangeSaaSToken: async (_code: string): Promise<LoginResponse> => {
throw new Error('SSO not implemented');
exchangeSaaSToken: async (grant: string): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>('/api/v1/auth/sso/exchange', { grant });
return response;
},
acceptInvite: async (email: string, tempPassword: string, newPassword: string): Promise<LoginResponse> => {
+27 -61
View File
@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert, ArrowRight } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe, LogOut, Shield, User, Settings, CheckCircle2, ChevronRight, Menu, ShieldAlert } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
import { useHeader } from "../../contexts/HeaderContext";
import { useSidebar } from "../../contexts/SidebarContext";
@@ -9,6 +9,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { notificationService } from "../../features/notifications";
import { tenantService } from "../../features/tenants/services/tenant.service";
import { notify } from "../../services/toast";
import { hasPermission } from "../../utils/permissionUtils";
export function Header() {
const { title, subtitle } = useHeader();
@@ -16,7 +17,7 @@ export function Header() {
const { language, setLanguage } = useLanguage();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { user } = useAppSelector((state) => state.auth);
const { user, permissions } = useAppSelector((state) => state.auth);
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [headerUnread, setHeaderUnread] = useState(0);
@@ -27,6 +28,7 @@ export function Header() {
const menuRef = useRef<HTMLDivElement>(null);
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const canViewNotifications = hasPermission(permissions, 'notifications', 'view', user);
// Derive real-time display values from Redux user state
const userName = user?.name || user?.user_name || (user?.email ? user.email.split('@')[0] : "User");
@@ -38,7 +40,7 @@ export function Header() {
// Load platform tenants if superadmin
const loadPlatformTenants = useCallback(async () => {
if (isPlatformUser) {
if (isPlatformUser && impersonatedTenantId) {
try {
const tenants = await tenantService.getPlatformTenants();
if (Array.isArray(tenants)) {
@@ -48,51 +50,42 @@ export function Header() {
// Fallback silently if not available
}
}
}, [isPlatformUser]);
}, [isPlatformUser, impersonatedTenantId]);
useEffect(() => {
loadPlatformTenants();
}, [loadPlatformTenants]);
useEffect(() => {
const syncImpersonation = (event: Event) => {
const tenantId = (event as CustomEvent<string | null>).detail;
setImpersonatedTenantId(tenantId || localStorage.getItem('impersonatedTenantId'));
};
window.addEventListener('pim:impersonation-changed', syncImpersonation);
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
}, []);
const activeTenantObj = platformTenants.find(t => String(t.id) === String(impersonatedTenantId));
const tenantName = impersonatedTenantId
? `${activeTenantObj?.name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
? `${activeTenantObj?.name || activeTenantObj?.tenant_name || 'Tenant'} (#${impersonatedTenantId}) [Support Mode]`
: (user?.tenant?.name || user?.tenant_name || (isPlatformUser ? "Platform Core" : `Tenant #${user?.tenant_id}`));
const handleWorkspaceChange = async (targetTenantId: string) => {
if (!targetTenantId) {
// Switch back to Global Platform Control
localStorage.removeItem('impersonatedTenantId');
setImpersonatedTenantId(null);
notify.info("Switched to Global SaaS Control Tower");
navigate("/platform/overview");
window.location.reload();
} else {
// Impersonate selected tenant
try {
await tenantService.impersonateTenant(targetTenantId);
localStorage.setItem('impersonatedTenantId', targetTenantId);
setImpersonatedTenantId(targetTenantId);
const selected = platformTenants.find(t => String(t.id) === String(targetTenantId));
notify.success(`Entered Support Mode: ${selected?.name || `Tenant #${targetTenantId}`}`);
navigate("/products");
window.location.reload();
} catch (err) {
notify.error("Unable to switch workspace context.");
}
}
};
const handleExitSupportMode = () => {
localStorage.removeItem('impersonatedTenantId');
setImpersonatedTenantId(null);
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: null }));
notify.info("Support impersonation session ended");
navigate("/platform/overview");
navigate("/platform/tenants");
window.location.reload();
};
useEffect(() => {
if (!canViewNotifications) {
setHeaderUnread(0);
return;
}
let mounted = true;
const sync = async () => {
const count = await notificationService.getUnreadCount();
@@ -116,7 +109,7 @@ export function Header() {
socketService.off("notification:unread-count", handleUnreadCount);
});
};
}, []);
}, [canViewNotifications]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -143,7 +136,7 @@ export function Header() {
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-950 animate-bounce" />
<span>
SUPPORT IMPERSONATION ACTIVE: You are viewing and operating inside workspace <strong>"{activeTenantObj?.name || 'Selected Tenant'}"</strong> (Tenant #{impersonatedTenantId}).
SUPPORT IMPERSONATION ACTIVE: You are viewing and operating inside workspace <strong>"{activeTenantObj?.name || activeTenantObj?.tenant_name || 'Selected Tenant'}"</strong> (Tenant #{impersonatedTenantId}).
</span>
</div>
<button
@@ -180,33 +173,6 @@ export function Header() {
{/* Right Side Controls */}
<div className="flex items-center gap-2 sm:gap-4">
{/* Platform SuperAdmin Workspace Switcher */}
{isPlatformUser && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-semibold border border-primary/30 bg-primary/5 text-primary shadow-2xs">
<Building2 className="w-4 h-4 text-primary shrink-0" />
<div className="flex flex-col text-left">
<span className="text-[9px] font-black uppercase text-primary/70 tracking-wider">Active Workspace</span>
<select
value={impersonatedTenantId || ''}
onChange={(e) => handleWorkspaceChange(e.target.value)}
className="bg-transparent outline-none cursor-pointer font-bold text-foreground text-xs pr-1"
aria-label="Select workspace context"
>
<option value="" className="bg-surface text-foreground font-semibold">
🌐 Global Platform Console
</option>
<optgroup label="Tenants / Organizations">
{platformTenants.map(t => (
<option key={t.id} value={t.id} className="bg-surface text-foreground font-medium">
🏢 {t.name} (#{t.id})
</option>
))}
</optgroup>
</select>
</div>
</div>
)}
{/* Standard Non-Platform Tenant Badge */}
{!isPlatformUser && (
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium border border-border bg-surface-muted text-foreground">
@@ -234,7 +200,7 @@ export function Header() {
</div>
{/* Notifications Bell */}
<div className="relative">
{canViewNotifications && <div className="relative">
<button
onClick={() => navigate("/notifications")}
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-muted transition-colors relative cursor-pointer"
@@ -247,7 +213,7 @@ export function Header() {
</span>
)}
</button>
</div>
</div>}
{/* Profile Card Trigger & Popover */}
<div className="relative" ref={menuRef}>
+13 -2
View File
@@ -6,6 +6,7 @@ import { AccessDenied } from '../customs/AccessDenied';
interface ProtectedRouteProps {
node: PermissionNodes | string;
action?: 'view' | 'create' | 'edit' | 'delete' | 'alter' | 'import' | 'export';
children: React.ReactNode;
fallback?: React.ReactNode; // Optional custom fallback
}
@@ -22,12 +23,22 @@ interface ProtectedRouteProps {
*/
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
node,
action = 'view',
children,
fallback = <AccessDenied />
}) => {
const { canView } = usePermissions(node);
const permissions = usePermissions(node);
const allowed = {
view: permissions.canView,
create: permissions.canCreate,
edit: permissions.canEdit,
delete: permissions.canDelete,
alter: permissions.canAlter,
import: permissions.canImport,
export: permissions.canExport,
}[action];
if (!canView) {
if (!allowed) {
return <>{fallback}</>;
}
+19 -4
View File
@@ -22,7 +22,7 @@ interface NavItem {
children?: NavItem[];
}
import { sidebarConfig as navItems } from "../../routes/sidebar.config";
import { sidebarConfig as tenantNavItems, platformSidebarConfig } from "../../routes/sidebar.config";
export function Sidebar() {
const navigate = useNavigate();
@@ -65,6 +65,18 @@ export function Sidebar() {
const permissions = useSelector((state: RootState) => state.auth.permissions);
const user = useSelector((state: RootState) => state.auth.user);
const isPlatformUser = user?.type === 'platform' || user?.user_type === 'platform';
const [impersonatedTenantId, setImpersonatedTenantId] = useState<string | null>(
localStorage.getItem('impersonatedTenantId')
);
const isSupportMode = isPlatformUser && Boolean(impersonatedTenantId);
React.useEffect(() => {
const syncImpersonation = (event: Event) => {
setImpersonatedTenantId((event as CustomEvent<string | null>).detail || localStorage.getItem('impersonatedTenantId'));
};
window.addEventListener('pim:impersonation-changed', syncImpersonation);
return () => window.removeEventListener('pim:impersonation-changed', syncImpersonation);
}, []);
const toggleSection = (href: string) => {
setExpandedSections(prev =>
@@ -107,7 +119,10 @@ export function Sidebar() {
}, []);
};
const filteredNavItems = filterNavItems(navItems);
const navigationSource = isPlatformUser && !isSupportMode
? platformSidebarConfig
: tenantNavItems.filter(item => !item.platformOnly);
const filteredNavItems = filterNavItems(navigationSource);
return (
<div
@@ -226,10 +241,10 @@ export function Sidebar() {
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
{isSupportMode ? `Tenant #${impersonatedTenantId}` : isPlatformUser ? 'Platform Super Admin' : (user?.tenant?.name || 'Tenant Workspace')}
</div>
<div className="text-xs text-muted-foreground truncate">
{isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
{isSupportMode ? 'Audited Support Mode' : isPlatformUser ? 'Global SaaS Mode' : `${user?.tenant?.plan_name || 'Active'} Plan`}
</div>
</div>
</div>
+147 -13
View File
@@ -10,6 +10,7 @@ import { TextArea } from "../../../components/customs/TextArea";
import { Select } from "../../../components/customs/Select";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import { notify } from "../../../services/toast";
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
const CATEGORIES = [
@@ -99,6 +100,35 @@ const CATEGORIES = [
},
] as const;
const POPULAR_EXTENSIONS = [
// Images
{ ext: 'jpg', category: 'image', label: 'JPG' },
{ ext: 'jpeg', category: 'image', label: 'JPEG' },
{ ext: 'png', category: 'image', label: 'PNG' },
{ ext: 'webp', category: 'image', label: 'WEBP' },
{ ext: 'gif', category: 'image', label: 'GIF' },
{ ext: 'svg', category: 'image', label: 'SVG' },
// Videos
{ ext: 'mp4', category: 'video', label: 'MP4' },
{ ext: 'mov', category: 'video', label: 'MOV' },
{ ext: 'avi', category: 'video', label: 'AVI' },
{ ext: 'webm', category: 'video', label: 'WEBM' },
// Documents
{ ext: 'pdf', category: 'document', label: 'PDF' },
{ ext: 'doc', category: 'document', label: 'DOC' },
{ ext: 'docx', category: 'document', label: 'DOCX' },
{ ext: 'xls', category: 'document', label: 'XLS' },
{ ext: 'xlsx', category: 'document', label: 'XLSX' },
{ ext: 'ppt', category: 'document', label: 'PPT' },
{ ext: 'pptx', category: 'document', label: 'PPTX' },
{ ext: 'txt', category: 'document', label: 'TXT' },
// Other
{ ext: 'zip', category: 'other', label: 'ZIP' },
{ ext: 'rar', category: 'other', label: 'RAR' },
{ ext: 'csv', category: 'other', label: 'CSV' },
{ ext: 'json', category: 'other', label: 'JSON' },
];
const STEPS = [
{ id: 'basic', label: 'Basic Information', step: 1 },
{ id: 'category', label: 'Asset Category', step: 2 },
@@ -190,6 +220,36 @@ export default function NewAssetType() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
// Sync validation errors and switch steps if submit is attempted with errors
useEffect(() => {
if (formik.submitCount > 0 && !formik.isSubmitting) {
const errors = formik.errors;
const errorKeys = Object.keys(errors);
if (errorKeys.length > 0) {
const messages: string[] = [];
if (errors.name) messages.push(errors.name);
if (errors.code) messages.push(errors.code);
if (errors.category) {
messages.push(errors.category);
setActiveStep('category');
} else if (errors.name || errors.code) {
setActiveStep('basic');
} else if (errors.validation) {
setActiveStep('validation');
const valErrors = errors.validation as any;
if (valErrors.maxFileSize) messages.push(valErrors.maxFileSize);
if (valErrors.allowedFileTypes) messages.push(valErrors.allowedFileTypes);
}
notify.error(`Please resolve validation errors: ${messages.join('; ')}`);
formik.setSubmitting(false);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formik.submitCount, formik.isSubmitting]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit && !formik.touched.code) {
@@ -199,8 +259,9 @@ export default function NewAssetType() {
};
const handleAddFileType = () => {
if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]);
const trimmed = newFileType.trim().toLowerCase().replace(/^\./, '');
if (trimmed && !formik.values.validation.allowedFileTypes.includes(trimmed)) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, trimmed]);
setNewFileType('');
}
};
@@ -404,29 +465,92 @@ export default function NewAssetType() {
<div className="bg-surface rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Validation Rules" subtitle="Enforced when assets are uploaded" />
<div className="p-6 space-y-6">
{/* Selected formats badges list */}
<div>
<label className={labelClass}>Allowed File Types <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-2 border border-primary/10 rounded-lg mb-2 flex flex-wrap gap-2 bg-background">
<label className={labelClass}>Allowed File Types Summary <span className="text-red-500">*</span></label>
<div className="min-h-[42px] p-3 border border-primary/10 rounded-lg mb-4 flex flex-wrap gap-2 bg-background">
{formik.values.validation.allowedFileTypes.length === 0 ? (
<span className="text-sm text-muted-foreground py-1 px-2">No file types added yet</span>
<span className="text-xs text-muted-foreground py-1 px-1">No file types selected yet. Check the boxes below to allow extensions.</span>
) : (
formik.values.validation.allowedFileTypes.map(type => (
<span key={type} className="inline-flex items-center gap-1 px-2 py-1 bg-surface border border-border rounded text-xs font-medium text-foreground">
<span key={type} className="inline-flex items-center gap-1 px-2.5 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground animate-fade-in shadow-2xs">
.{type}
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500"><X className="w-3 h-3" /></button>
<button type="button" onClick={() => removeFileType(type)} className="text-muted-foreground hover:text-red-500 ml-1 transition-colors"><X className="w-3 h-3" /></button>
</span>
))
)}
</div>
<div className="flex gap-2">
</div>
{/* Multiselect checkboxes for popular formats */}
<div>
<label className={labelClass}>Select Allowed Formats</label>
<div className="bg-background border border-primary/10 rounded-xl p-5 space-y-5">
{['image', 'video', 'document', 'other'].map(group => {
const exts = POPULAR_EXTENSIONS.filter(e => e.category === group);
const groupLabel = group === 'image' ? 'Image Formats' : group === 'video' ? 'Video Formats' : group === 'document' ? 'Document Formats' : 'Data & Archive Formats';
return (
<div key={group} className="space-y-2">
<div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">{groupLabel}</div>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
{exts.map(item => {
const isChecked = formik.values.validation.allowedFileTypes.includes(item.ext);
return (
<label
key={item.ext}
className={`
flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-all select-none
${isChecked
? 'bg-primary/5 border-primary text-primary font-bold shadow-2xs'
: 'bg-surface border-border text-foreground hover:border-primary/20 hover:bg-background/20'
}
`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const current = formik.values.validation.allowedFileTypes || [];
if (e.target.checked) {
formik.setFieldValue('validation.allowedFileTypes', [...current, item.ext]);
} else {
formik.setFieldValue('validation.allowedFileTypes', current.filter(t => t !== item.ext));
}
}}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
<span className="text-xs">.{item.ext}</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Custom Extension Input */}
<div className="pt-2">
<label className="block text-[11px] font-semibold text-muted-foreground mb-1">Add Custom Extension (Optional)</label>
<div className="flex gap-2 max-w-sm">
<Input
value={newFileType}
onChange={(e) => setNewFileType(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddFileType(); } }}
placeholder="Type extension and press Enter (e.g. jpg)"
placeholder="e.g. psd"
className="text-xs"
/>
<button type="button" onClick={handleAddFileType} className="px-3 py-2 border border-primary/10 rounded-lg hover:bg-background">
<Plus className="w-4 h-4 text-muted-foreground" />
<button
type="button"
onClick={handleAddFileType}
className="px-4 py-2 bg-surface hover:bg-background border border-border rounded-lg text-xs font-semibold text-foreground flex items-center justify-center transition-colors"
title="Add custom format"
>
<Plus className="w-4 h-4 text-muted-foreground mr-1" />
Add
</button>
</div>
</div>
@@ -528,12 +652,22 @@ export default function NewAssetType() {
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
<div className="shrink-0 pt-4 flex justify-end gap-2">
{activeIndex > 0 && (
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
{activeIndex < STEPS.length - 1 ? (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next</Button>
) : (
<Button
variant="primary"
type="submit"
form="asset-type-form"
icon={<Save className="w-4 h-4" />}
loading={formik.isSubmitting}
>
{isEdit ? 'Save Changes' : 'Create Asset Type'}
</Button>
)}
</div>
</div>
@@ -14,6 +14,8 @@ export interface AssetType {
description?: string;
status: 'active' | 'inactive';
isRequired: boolean;
isVariantEligible?: boolean;
is_variant_eligible?: boolean;
category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | '';
validation: AssetTypeValidation;
createdAt: string;
+28 -9
View File
@@ -86,49 +86,68 @@ export const assetsApi = {
// Product Assets Assignment
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
getAllVariantAssets: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`/api/v1/products/${productId}/all-variant-assets`);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
bulkAssignVariantAsset: async (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }): Promise<any[]> => {
const res = await apiClient.post<ApiResponse<any[]>>(`/api/v1/products/${productId}/assets/bulk-assign`, body);
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
// Variant Assets Assignment
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
},
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
return res.data;
const raw: any = res.data;
return raw?.data ? raw.data : raw;
},
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
return res.success;
return res.data?.success ?? true;
},
// Product variants list for the variant select dropdown
getProductVariants: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
return res.data || [];
const raw: any = res.data;
return (Array.isArray(raw) ? raw : raw?.data) || [];
}
};
+3 -2
View File
@@ -1,11 +1,12 @@
import { Routes, Route } from 'react-router-dom';
import AssetList from '../pages/AssetList';
import NewAsset from '../pages/NewAsset';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const AssetRoutes = () => (
<Routes>
<Route index element={<AssetList />} />
<Route path="new" element={<NewAsset />} />
<Route path=":id/edit" element={<NewAsset />} />
<Route path="new" element={<ProtectedRoute node="media.assets" action="create"><NewAsset /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="media.assets" action="edit"><NewAsset /></ProtectedRoute>} />
</Routes>
);
@@ -28,6 +28,8 @@ export interface AssetAnalytics {
export interface AssetMapping {
id: string;
asset_id: string;
product_id?: string;
productId?: string;
role: string;
display_order: number;
is_primary: boolean;
@@ -49,9 +51,11 @@ export const assetsService = {
getFolders: () => assetsApi.getFolders(),
getTags: () => assetsApi.getTags(),
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
getAllVariantAssets: (productId: string) => assetsApi.getAllVariantAssets(productId),
assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body),
updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body),
unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId),
bulkAssignVariantAsset: (productId: string, body: { asset_id: string; role: string; variant_ids: string[]; is_primary?: boolean }) => assetsApi.bulkAssignVariantAsset(productId, body),
getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId),
assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body),
updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body),
@@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
<CardHeader title="Basic Information" subtitle="Define the group's identity and metadata" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., general_info"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Group Name <span className="text-red-400">*</span></label>
<input
@@ -230,6 +216,20 @@ export default function NewAttributeGroup() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., general_info"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -228,20 +228,6 @@ export default function NewAttributeSet() {
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., electronic_accessories"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
<input
@@ -259,6 +245,20 @@ export default function NewAttributeSet() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., electronic_accessories"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
/>
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -17,7 +17,7 @@ import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
export default function AttributeList() {
const { canCreate, canEdit, canDelete } = usePermissions("products.attributes");
const { canEdit, canDelete } = usePermissions("products.attributes");
const navigate = useNavigate();
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
+21 -13
View File
@@ -176,6 +176,14 @@ export default function NewAttribute() {
apiVisible: (match as any).apiVisible ?? true,
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
});
if ((match as any).optionsList && Array.isArray((match as any).optionsList)) {
setOptionsList((match as any).optionsList.map((o: any) => ({ code: o.code, label: o.label })));
} else if ((match as any).options && Array.isArray((match as any).options)) {
setOptionsList((match as any).options.map((o: any) => typeof o === 'string' ? { code: o.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''), label: o } : o));
} else {
setOptionsList([]);
}
}
});
}
@@ -283,19 +291,6 @@ export default function NewAttribute() {
<CardHeader title="General Information" subtitle="Basic details about the attribute" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit || isView}
placeholder="e.g., product_weight"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Attribute Name <span className="text-red-400">*</span></label>
<Input
@@ -309,6 +304,19 @@ export default function NewAttribute() {
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit || isView}
placeholder="e.g., product_weight"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
<div>
@@ -2,14 +2,15 @@ import { Routes, Route } from 'react-router-dom';
import AttributeList from '../pages/AttributeList';
import NewAttribute from '../pages/NewAttribute';
import AttributeGroupList from '../../attribute-groups/pages/AttributeGroupList';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const AttributeRoutes = () => {
return (
<Routes>
<Route index element={<AttributeList />} />
<Route path="groups" element={<AttributeGroupList />} />
<Route path="new" element={<NewAttribute />} />
<Route path=":id/edit" element={<NewAttribute />} />
<Route path="new" element={<ProtectedRoute node="products.attributes" action="create"><NewAttribute /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.attributes" action="edit"><NewAttribute /></ProtectedRoute>} />
<Route path=":id/view" element={<NewAttribute />} />
</Routes>
);
@@ -7,8 +7,8 @@ import type { Brand } from '../types/brand.types';
interface BrandTableProps {
brands: Brand[];
onView: (row: Brand) => void;
onEdit: (row: Brand) => void;
onDelete: (row: Brand) => void;
onEdit?: (row: Brand) => void;
onDelete?: (row: Brand) => void;
onRowClick?: (row: Brand) => void;
}
+2 -2
View File
@@ -55,8 +55,8 @@ export default function BrandList() {
brands={brands}
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
onView={(row) => navigate(`/brands/${row.id}/view`)}
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : undefined}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : undefined}
onEdit={canEdit ? ((row) => navigate(`/brands/${row.id}/edit`)) : () => {}}
onDelete={canDelete ? ((row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })) : () => {}}
/>
</div>
+3 -2
View File
@@ -1,13 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import BrandList from '../pages/BrandList';
import NewBrandForm from '../pages/NewBrandForm';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const BrandRoutes = () => {
return (
<Routes>
<Route index element={<BrandList />} />
<Route path="new" element={<NewBrandForm />} />
<Route path=":id/edit" element={<NewBrandForm />} />
<Route path="new" element={<ProtectedRoute node="masters.brands" action="create"><NewBrandForm /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="masters.brands" action="edit"><NewBrandForm /></ProtectedRoute>} />
<Route path=":id/view" element={<NewBrandForm />} />
</Routes>
);
@@ -101,6 +101,7 @@ function TreeNodeRow({
const hasChildren = node.children.length > 0;
const isRoot = node.depth === 0;
const isActive = node.category.status === "active";
const isSharedBaseline = node.category.tenant_id == null;
const linePrefix = node.depth === 0 ? "" : prefix + (isLast ? "└── " : "├── ");
const childPrefix = node.depth === 0 ? "" : prefix + (isLast ? " " : "│ ");
const descendants = countDescendants(node);
@@ -220,22 +221,26 @@ function TreeNodeRow({
>
<Plus className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onEdit(node.category)}
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Edit"
>
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(node.category)}
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Delete"
>
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
{!isSharedBaseline && (
<>
<button
type="button"
onClick={() => onEdit(node.category)}
className="p-2 md:p-1 rounded hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Edit"
>
<Pencil className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(node.category)}
className="p-2 md:p-1 rounded hover:bg-red-50 text-muted-foreground hover:text-red-500 transition-colors flex items-center justify-center min-h-[32px] min-w-[32px] md:min-h-0 md:min-w-0"
title="Delete"
>
<Trash2 className="w-4 h-4 md:w-3.5 md:h-3.5" />
</button>
</>
)}
</span>
</div>
@@ -35,8 +35,8 @@ export default function CategoryList() {
const stats = {
total: categories.length || 0,
active: categories.filter((c) => c.status === "active").length,
products: categories.reduce((sum, c) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c) => sum + (Number(c.familyCount) || 0), 0),
products: categories.reduce((sum, c: any) => sum + (Number(c.productCount) || 0), 0),
families: categories.reduce((sum, c: any) => sum + (Number(c.familyCount) || 0), 0),
};
const handleDeleteConfirm = async () => {
@@ -2,12 +2,13 @@ import { Routes, Route } from 'react-router-dom';
import CategoryList from '../pages/CategoryList';
import NewCategory from '../pages/NewCategory';
import CategoryView from '../pages/CategoryView';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const CategoryRoutes = () => (
<Routes>
<Route index element={<CategoryList />} />
<Route path="new" element={<NewCategory />} />
<Route path=":id/edit" element={<NewCategory />} />
<Route path="new" element={<ProtectedRoute node="products.categories" action="create"><NewCategory /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.categories" action="edit"><NewCategory /></ProtectedRoute>} />
<Route path=":id/view" element={<CategoryView />} />
</Routes>
);
@@ -11,6 +11,9 @@ export interface Category {
lastUpdated: string;
createdBy: string;
path?: string;
tenant_id?: string | number | null;
productCount?: number;
familyCount?: number;
}
export type CategoryCreateRequest = Omit<Category, 'id' | 'lastUpdated' | 'createdBy' | 'parentName'>;
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import {
Plus,
import { useEffect } from "react";
import {
RefreshCw,
Layers2,
CheckCircle,
@@ -17,7 +16,6 @@ import {
Monitor
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
@@ -25,7 +23,6 @@ import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { useChannelType } from "../hook/useChannelType";
import type { ChannelType } from "../types/channel-types.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
const ICON_MAP: Record<string, any> = {
@@ -41,15 +38,7 @@ const ICON_MAP: Record<string, any> = {
};
export default function ChannelTypeList() {
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannelType();
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false,
id: "",
name: "",
});
const [isDeleting, setIsDeleting] = useState(false);
const { items, fetchItems, loading } = useChannelType();
useEffect(() => {
fetchItems();
@@ -112,19 +101,6 @@ export default function ChannelTypeList() {
},
];
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (error) {
console.error(error);
} finally {
setIsDeleting(false);
}
};
return (
<PageWrapper>
<Breadcrumb
@@ -139,10 +115,6 @@ export default function ChannelTypeList() {
<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" />
Add Channel Type
</Button>
</>
}
/>
@@ -185,20 +157,6 @@ export default function ChannelTypeList() {
rowIdKey="id"
resultLabel="channel types"
statusKey="status"
actionConfig={{
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
}}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Channel Type"
description="Are you sure you want to delete this channel type? Channels using this type may be affected."
itemName={deleteModal.name}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
);
@@ -1,68 +1,34 @@
import apiClient from '../../../api/axiosInstance';
import type { ChannelType, ChannelTypeCreateRequest, ChannelTypeUpdateRequest } from '../types/channel-types.types';
const STORAGE_KEY = 'pim_channel_types';
const INITIAL_DATA: ChannelType[] = [
{ id: '1', name: 'E-Commerce', code: 'ecommerce', description: 'Online retail storefronts and shopping platforms', icon: 'ShoppingCart', status: 'active', channelCount: 4, createdAt: '2025-01-10', author: 'Admin User' },
{ id: '2', name: 'Marketplace', code: 'marketplace', description: 'Third-party marketplace listings like Amazon and eBay', icon: 'ShoppingBag', status: 'active', channelCount: 3, createdAt: '2025-01-12', author: 'Sarah Chen' },
{ id: '3', name: 'ERP System', code: 'erp', description: 'Enterprise resource planning system integrations', icon: 'Server', status: 'active', channelCount: 2, createdAt: '2025-01-15', author: 'Michael Torres' },
{ id: '4', name: 'Warehouse (WMS)', code: 'wms', description: 'Warehouse management system for stock and logistics', icon: 'Warehouse', status: 'active', channelCount: 1, createdAt: '2025-01-18', author: 'Emma Wilson' },
{ id: '5', name: 'Point of Sale', code: 'pos', description: 'In-store point of sale and retail terminal systems', icon: 'Store', status: 'active', channelCount: 2, createdAt: '2025-02-01', author: 'Admin User' },
{ id: '6', name: 'B2B Portal', code: 'b2b_portal', description: 'Business-to-business buyer portals and wholesale platforms', icon: 'Globe', status: 'active', channelCount: 1, createdAt: '2025-02-10', author: 'Sarah Chen' },
{ id: '7', name: 'Mobile App', code: 'mobile_app', description: 'Native mobile applications for iOS and Android', icon: 'Smartphone', status: 'inactive', channelCount: 0, createdAt: '2025-02-20', author: 'Michael Torres' },
{ id: '8', name: 'Corporate Website', code: 'website', description: 'Company marketing websites and product catalogues', icon: 'Monitor', status: 'active', channelCount: 2, createdAt: '2025-03-01', author: 'Emma Wilson' },
];
const getStored = (): ChannelType[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
return INITIAL_DATA;
}
return JSON.parse(stored);
};
const setStored = (items: ChannelType[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
interface ApiResponse<T> { success: boolean; data: T; message?: string }
const base = '/api/v1/channel-types';
const normalize = (raw: any): ChannelType => ({
...raw,
createdAt: raw.createdAt || raw.created_at,
updatedAt: raw.updatedAt || raw.updated_at,
channelCount: raw.channelCount ?? raw.channels?.length ?? 0
});
export const channelTypesService = {
getAll: async (): Promise<ChannelType[]> =>
new Promise((resolve) => setTimeout(() => resolve(getStored()), 300)),
getById: async (id: string): Promise<ChannelType | undefined> =>
new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200)),
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: ChannelType = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
}),
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> =>
new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
}),
delete: async (id: string): Promise<boolean> =>
new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
}),
getAll: async (): Promise<ChannelType[]> => {
const response = await apiClient.get<ApiResponse<any[]>>(base);
return (response.data || []).map(normalize);
},
getById: async (id: string): Promise<ChannelType | undefined> => {
const response = await apiClient.get<ApiResponse<any>>(`${base}/${id}`);
return response.data ? normalize(response.data) : undefined;
},
create: async (req: ChannelTypeCreateRequest): Promise<ChannelType> => {
const response = await apiClient.post<ApiResponse<any>>(base, req);
return normalize(response.data);
},
update: async (id: string, req: ChannelTypeUpdateRequest): Promise<ChannelType> => {
const response = await apiClient.put<ApiResponse<any>>(`${base}/${id}`, req);
return normalize(response.data);
},
delete: async (id: string): Promise<boolean> => {
const response = await apiClient.delete<ApiResponse<unknown>>(`${base}/${id}`);
return response.success;
}
};
+49 -5
View File
@@ -1,4 +1,4 @@
import apiClient from '../../../api/axiosInstance';
import apiClient, { axiosInstance } from '../../../api/axiosInstance';
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
interface ApiResponse<T> {
@@ -8,26 +8,33 @@ interface ApiResponse<T> {
}
const BASE_URL = '/api/v1/channels';
const normalizeChannel = (raw: any): Channel => ({
...raw,
channelType: raw.channelType || raw.type_id || '',
allowPublishing: raw.allowPublishing ?? raw.metadata?.allowPublishing ?? true,
createdAt: raw.createdAt || raw.created_at,
updatedAt: raw.updatedAt || raw.updated_at
});
export const channelsApi = {
getAll: async (): Promise<Channel[]> => {
const res = await apiClient.get<ApiResponse<Channel[]>>(BASE_URL);
return res.data || [];
return (res.data || []).map(normalizeChannel);
},
getById: async (id: string): Promise<Channel | undefined> => {
const res = await apiClient.get<ApiResponse<Channel>>(`${BASE_URL}/${id}`);
return res.data;
return res.data ? normalizeChannel(res.data) : undefined;
},
create: async (req: ChannelCreateRequest): Promise<Channel> => {
const res = await apiClient.post<ApiResponse<Channel>>(BASE_URL, req);
return res.data;
return normalizeChannel(res.data);
},
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
const res = await apiClient.put<ApiResponse<Channel>>(`${BASE_URL}/${id}`, req);
return res.data;
return normalizeChannel(res.data);
},
remove: async (id: string): Promise<boolean> => {
@@ -69,6 +76,43 @@ export const channelsApi = {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/${channelId}/test-connection`);
return res.data;
},
getAllJobs: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/jobs`);
return res.data || [];
},
getErrors: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/errors?includeRetrying=true`);
return res.data || [];
},
getQueueHealth: async (): Promise<any> => {
const res = await apiClient.get<ApiResponse<any>>(`${BASE_URL}/queue/health`);
return res.data;
},
getOperationsAudit: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/operations/audit`);
return res.data || [];
},
cancelJob: async (jobId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/cancel`);
return res.data;
},
retryJob: async (jobId: string): Promise<any> => {
const res = await apiClient.post<ApiResponse<any>>(`${BASE_URL}/jobs/${jobId}/retry`);
return res.data;
},
downloadCsv: async (channelId: string): Promise<{ blob: Blob; filename: string }> => {
const response = await axiosInstance.get(`${BASE_URL}/${channelId}/export.csv`, { responseType: 'blob' });
const disposition = String(response.headers['content-disposition'] || '');
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || 'channel-products.csv';
return { blob: response.data, filename };
},
};
export default channelsApi;
@@ -13,15 +13,6 @@ const COMMON_PIM_ATTRIBUTES = [
{ code: "created_at", label: "Creation Timestamp (created_at)" },
];
const COMMON_CHANNEL_FIELDS = [
{ code: "title", label: "Storefront Title (title)" },
{ code: "body_html", label: "HTML Body Description (body_html)" },
{ code: "variant_sku", label: "Variant SKU (variant_sku)" },
{ code: "price", label: "Variant Price (price)" },
{ code: "vendor", label: "Brand / Vendor (vendor)" },
{ code: "product_type", label: "Product Category / Type (product_type)" },
];
const TRANSFORMATION_RULES = [
{ value: "none", label: "Direct Pass-through" },
{ value: "uppercase", label: "UPPERCASE" },
@@ -35,7 +35,7 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
setSyncing(true);
try {
await channelsApi.triggerSyndication(channelId);
notify.success("Syndication job triggered and processed successfully!");
notify.success("Syndication job queued successfully");
await loadJobs();
} catch {
notify.error("Failed to trigger syndication job");
@@ -52,6 +52,13 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Failed</span>;
case 'running':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20 animate-pulse"><Clock className="w-3.5 h-3.5" /> Running</span>;
case 'retrying':
case 'queued':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-warning/10 text-warning border border-warning/20"><Clock className="w-3.5 h-3.5" /> {status}</span>;
case 'partial':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-danger/10 text-danger border border-danger/20"><AlertCircle className="w-3.5 h-3.5" /> Partial</span>;
case 'cancelled':
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Cancelled</span>;
default:
return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground">Pending</span>;
}
@@ -141,8 +148,8 @@ export function SyndicationHistoryTab({ channelId }: { channelId: string }) {
<div className="max-h-80 overflow-y-auto space-y-2">
{selectedErrorLog.map((err, idx) => (
<div key={idx} className="p-3 bg-danger/5 border border-danger/20 rounded-lg text-xs font-mono">
<div className="font-semibold text-danger">Product SKU: {err.sku || 'N/A'} (ID: {err.productId})</div>
<div className="text-muted-foreground mt-1">{err.error}</div>
<div className="font-semibold text-danger">Product: {err.sku || err.product_code || 'N/A'} (ID: {err.productId || err.product_id || 'N/A'})</div>
<div className="text-muted-foreground mt-1">{err.error || err.error_message || err.message}</div>
</div>
))}
</div>
+39 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play } from "lucide-react";
import { Plus, RefreshCw, Radio, CheckCircle, Clock, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor, Play, CircleHelp, Download } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -16,6 +16,7 @@ import { notify } from "../../../services/toast";
import { Can } from "../../../components/customs/Can";
import { usePermissions } from "../../../hooks/usePermission";
import { useChannelType } from "../../channel-types/hook/useChannelType";
const CHANNEL_TYPES_META: Record<string, { label: string, icon: any, typeColor: string, typeBg: string }> = {
ecommerce: { label: "Ecommerce", icon: ShoppingCart, typeColor: "text-blue-600", typeBg: "bg-blue-50" },
@@ -32,12 +33,20 @@ export default function ChannelList() {
const { canEdit, canDelete } = usePermissions("channels.syndication");
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannel();
const { items: channelTypes, fetchItems: fetchChannelTypes } = useChannelType();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
const [operations, setOperations] = useState<{ ready: number; published: number }>({ ready: 0, published: 0 });
useEffect(() => {
fetchItems();
}, [fetchItems]);
fetchChannelTypes();
Promise.all([channelsApi.getQueueHealth(), channelsApi.getAllJobs()])
.then(([health, jobs]) => setOperations({ ready: health.ready || 0, published: jobs.reduce((sum, job) => sum + Number(job.success_count || 0), 0) }))
.catch(() => setOperations({ ready: 0, published: 0 }));
}, [fetchItems, fetchChannelTypes]);
const typeCodeFor = (value?: string) => channelTypes.find(type => type.id === value)?.code || value || '';
const columns = [
{
@@ -45,7 +54,7 @@ export default function ChannelList() {
label: "Channel Name",
sortable: true,
render: (_: any, row: Channel) => {
const meta = CHANNEL_TYPES_META[row.channelType || ""] || CHANNEL_TYPES_META.website;
const meta = CHANNEL_TYPES_META[typeCodeFor(row.channelType)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
@@ -69,7 +78,7 @@ export default function ChannelList() {
key: "channelType",
label: "Type",
render: (val: string) => {
const meta = CHANNEL_TYPES_META[val] || CHANNEL_TYPES_META.website;
const meta = CHANNEL_TYPES_META[typeCodeFor(val)] || { label: "Unassigned", icon: CircleHelp, typeColor: "text-muted-foreground", typeBg: "bg-surface-muted" };
const Icon = meta.icon;
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border border-transparent ${meta.typeBg} ${meta.typeColor}`}>
@@ -88,19 +97,15 @@ export default function ChannelList() {
{ key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 },
{
key: "syndicate",
label: "Syndication",
label: "Actions",
render: (_: any, row: Channel) => (
<button
<div className="flex items-center gap-1.5"><button
type="button"
onClick={async () => {
try {
notify.info(`Triggering syndication for ${row.name}...`);
const res = await channelsApi.triggerSyndication(row.id);
if (res.status === 'completed') {
notify.success(`Syndication for ${row.name} completed! (${res.success_count} synced)`);
} else {
notify.warning(`Syndication for ${row.name} completed with ${res.failed_count} errors`);
}
notify.success(`Syndication for ${row.name} queued (${res.total_products || 0} products)`);
} catch {
notify.error("Failed to trigger syndication");
}
@@ -108,7 +113,20 @@ export default function ChannelList() {
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-primary bg-primary/10 hover:bg-primary/20 rounded transition-colors"
>
<Play className="w-3 h-3" /> Trigger Sync
</button>
</button><button
type="button"
onClick={async () => {
try {
const { blob, filename } = await channelsApi.downloadCsv(row.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = filename; anchor.click();
URL.revokeObjectURL(url);
notify.success(`Downloaded ${filename}`);
} catch { notify.error("Configure Channel mappings before downloading CSV"); }
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-foreground bg-surface-muted hover:bg-border rounded transition-colors"
><Download className="w-3 h-3"/> Download CSV</button></div>
),
},
{
@@ -153,7 +171,7 @@ export default function ChannelList() {
try {
notify.info("Triggering bulk syndication across all active channels...");
const results = await channelsApi.syndicateAll();
notify.success(`Bulk syndication complete! Triggered sync for ${results.length} active channels.`);
notify.success(`Queued syndication for ${results.length} active channels.`);
} catch {
notify.error("Failed to trigger bulk channel syndication");
}
@@ -179,29 +197,29 @@ export default function ChannelList() {
{/* Stats Cards */}
<StatsCard
title="Total Channels"
value="9"
value={items.length}
subtitle="Active and inactive"
color="purple"
icon={<Radio className="w-5 h-5" />}
/>
<StatsCard
title="Active Channels"
value="8"
value={items.filter(item => item.status === 'active').length}
subtitle="Serving traffic"
color="green"
icon={<CheckCircle className="w-5 h-5" />}
/>
<StatsCard
title="Families Assigned"
value="183"
subtitle="Across all storefronts"
title="Ready Queue Items"
value={operations.ready}
subtitle="Awaiting worker execution"
color="blue"
icon={<Layers className="w-5 h-5" />}
icon={<Clock className="w-5 h-5" />}
/>
<StatsCard
title="Products Published"
value="69,905"
subtitle="Synced items"
value={operations.published}
subtitle="Persisted successful deliveries"
color="orange"
icon={<TrendingUp className="w-5 h-5" />}
/>
@@ -1,12 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import ChannelList from '../pages/ChannelList';
import NewChannel from '../pages/NewChannel';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const ChannelRoutes = () => (
<Routes>
<Route index element={<ChannelList />} />
<Route path="new" element={<NewChannel />} />
<Route path=":id/edit" element={<NewChannel />} />
<Route path="new" element={<ProtectedRoute node="channels.syndication" action="create"><NewChannel /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="channels.syndication" action="edit"><NewChannel /></ProtectedRoute>} />
<Route path=":id/view" element={<NewChannel />} />
</Routes>
);
+1 -1
View File
@@ -93,7 +93,7 @@ export default function Dashboard() {
<PageWrapper>
<div className="mb-6">
<h2 className="text-xl font-bold text-foreground mb-1">
Welcome back, {user?.first_name || user?.user_name || (user?.user_type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')}
Welcome back, {user?.first_name || user?.user_name || user?.name || (user?.user_type === 'platform' || user?.type === 'platform' ? 'Platform Super Admin' : 'Tenant Administrator')}
</h2>
<p className="text-sm text-muted-foreground">Here's what's happening with your product catalog today.</p>
</div>
+673 -133
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import apiClient from '../../../api/axiosInstance';
import { useFamily } from '../hook/useFamily';
@@ -6,14 +6,16 @@ import { useAttribute } from '../../attributes/hook/useAttribute';
import { useChannel } from '../../channels/hook/useChannel';
import { useWorkflow } from '../../workflow/hook/useWorkflow';
import { StageTimeline } from '../../workflow/components/StageTimeline';
import { Search, X, Layers, Activity } from 'lucide-react';
import { Search, X, Layers, Activity, Plus, Loader2, ChevronDown } from 'lucide-react';
import { useUnit } from '../../units/hook/useUnit';
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup';
import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service';
import { useAssetFamily } from '../../asset-families/hook/useAssetFamily';
import { useBrand } from '../../brands/hook/useBrand';
import {
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
CheckCircle2, AlertCircle, Image as ImageIcon, Check,
Box, Info
} from 'lucide-react';
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -35,8 +37,7 @@ const TABS = [
{ id: 'channels', label: 'Allowed Channels', icon: Globe, step: 4 },
{ id: 'assets', label: 'Asset Families', icon: ImageIcon, step: 5 },
{ id: 'workflow', label: 'Workflow Assignment', icon: Settings2, step: 6 },
{ id: 'rules', label: 'Completeness Rules', icon: CheckSquare, step: 7 },
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 8 },
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 7 },
];
const inputClass = "w-full border border-primary/10 focus:ring-primary-light rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface placeholder-muted-foreground";
@@ -75,7 +76,8 @@ export default function NewFamily() {
const isEdit = Boolean(id) && !isView;
const { createFamily, updateFamily } = useFamily();
const { attributes, fetchAttributes, loading: attributesLoading } = useAttribute();
const { attributes, fetchAttributes, createAttribute, loading: attributesLoading } = useAttribute();
const { items: attributeGroupsList, fetchItems: fetchAttributeGroups } = useAttributeGroup();
const { items: channelsList, fetchItems: fetchChannels, loading: channelsLoading } = useChannel();
const { items: assetFamiliesList, fetchItems: fetchAssetFamilies, loading: assetFamiliesLoading } = useAssetFamily();
const { items: workflowsList, fetchItems: fetchWorkflows, loading: workflowsLoading } = useWorkflow();
@@ -95,6 +97,54 @@ export default function NewFamily() {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
// Step 2 Filtering & Inline Quick Create Attribute state
const [selectedGroupFilter, setSelectedGroupFilter] = useState('');
const [attributeSearchQuery, setAttributeSearchQuery] = useState('');
// Searchable Attribute Set Dropdown state & ref
const [isAttributeSetDropdownOpen, setIsAttributeSetDropdownOpen] = useState(false);
const [attributeSetSearchQuery, setAttributeSetSearchQuery] = useState('');
const attributeSetDropdownRef = useRef<HTMLDivElement>(null);
// Step 5 Asset Families Search & Dropdown state & ref
const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState('');
const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false);
const assetFamilyDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (attributeSetDropdownRef.current && !attributeSetDropdownRef.current.contains(event.target as Node)) {
setIsAttributeSetDropdownOpen(false);
}
if (assetFamilyDropdownRef.current && !assetFamilyDropdownRef.current.contains(event.target as Node)) {
setIsAssetFamilyDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const filteredAttributeSets = useMemo(() => {
if (!attributeSetSearchQuery.trim()) return attributeSetsList;
const q = attributeSetSearchQuery.toLowerCase().trim();
return attributeSetsList.filter((s: any) =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.code && s.code.toLowerCase().includes(q))
);
}, [attributeSetsList, attributeSetSearchQuery]);
const [showAttributeModal, setShowAttributeModal] = useState(false);
const [inlineAttributeName, setInlineAttributeName] = useState('');
const [inlineAttributeType, setInlineAttributeType] = useState('text');
const [inlineAttributeRequired, setInlineAttributeRequired] = useState(false);
const [inlineAttributeOptions, setInlineAttributeOptions] = useState('');
const [inlineAttributeGroupId, setInlineAttributeGroupId] = useState('');
const [inlineAttributeSubmitting, setInlineAttributeSubmitting] = useState(false);
// Maps form fields to which tab they live on for auto-navigation
const FIELD_TAB_MAP: Record<string, string> = {
name: 'basic', code: 'basic', status: 'basic',
@@ -103,7 +153,6 @@ export default function NewFamily() {
variantAxes: 'variants',
channels: 'channels',
assetRequirements: 'assets',
completenessRules: 'rules',
};
@@ -157,6 +206,16 @@ export default function NewFamily() {
onSubmit: async (values, { setSubmitting }) => {
setSubmitError(null);
// Validate completeness rules sum to 100%
const rulesSum = Object.values(values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
if (rulesSum !== 100) {
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
notify.error(msg);
setSubmitError(msg);
setSubmitting(false);
return;
}
// Validate the full form first and navigate to first tab with an error
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
@@ -215,13 +274,14 @@ export default function NewFamily() {
// Fetch dynamic lookup lists on mount
useEffect(() => {
fetchAttributes();
fetchAttributeGroups();
fetchChannels();
fetchAssetFamilies();
fetchWorkflows();
fetchAttributeSets();
fetchBrands();
fetchUnits();
}, [fetchAttributes, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
}, [fetchAttributes, fetchAttributeGroups, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
// Load family details in Edit mode
useEffect(() => {
@@ -298,6 +358,34 @@ export default function NewFamily() {
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const isLoading = familyLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
const familyCompleteness = useMemo(() => {
let score = 0;
if (formik.values.name && formik.values.code) score += 20;
if (formik.values.category) score += 20;
if (formik.values.attributeSetId) score += 20;
if (formik.values.channels && formik.values.channels.length > 0) score += 20;
if (formik.values.assetRequirements && formik.values.assetRequirements.length > 0) score += 20;
return score;
}, [formik.values.name, formik.values.code, formik.values.category, formik.values.attributeSetId, formik.values.channels, formik.values.assetRequirements]);
const assignedAssetFamiliesList = useMemo(() => {
const assignedIds = new Set(formik.values.assetRequirements || []);
return assetFamiliesList.filter((af: any) => assignedIds.has(af.id));
}, [assetFamiliesList, formik.values.assetRequirements]);
const filteredUnassignedAssetFamilies = useMemo(() => {
const assignedIds = new Set(formik.values.assetRequirements || []);
let unassigned = assetFamiliesList.filter((af: any) => !assignedIds.has(af.id));
if (assetFamilySearchQuery.trim()) {
const q = assetFamilySearchQuery.toLowerCase().trim();
unassigned = unassigned.filter((af: any) =>
(af.name && af.name.toLowerCase().includes(q)) ||
(af.code && af.code.toLowerCase().includes(q))
);
}
return unassigned;
}, [assetFamiliesList, formik.values.assetRequirements, assetFamilySearchQuery]);
// Selected attributes list for Step 3 Axis Filtering
const selectedAttributesList = useMemo(() => {
const selectedIds = new Set(formik.values.attributes || []);
@@ -402,6 +490,16 @@ export default function NewFamily() {
const handleSaveFamily = async () => {
setSubmitError(null);
// Validate completeness rules sum to 100%
const rulesSum = Object.values(formik.values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
if (rulesSum !== 100) {
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
notify.error(msg);
setSubmitError(msg);
return;
}
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
@@ -561,6 +659,47 @@ export default function NewFamily() {
);
})}
</nav>
{/* Completeness Score */}
<div className="mx-3 mb-3 border border-primary/10 rounded-lg p-3 bg-primary/5">
<p className="text-[10px] font-semibold text-primary uppercase tracking-widest mb-2">Completeness</p>
<div className="flex justify-center mb-3">
<div className="relative w-16 h-16">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--color-border)" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="var(--color-primary)"
strokeWidth="10"
strokeDasharray="283"
strokeDashoffset={283 - (283 * familyCompleteness) / 100}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-sm font-bold text-foreground">
{familyCompleteness}%
</span>
</div>
</div>
</div>
<div className="space-y-1.5 text-[11px]">
{[
{ label: 'Variants', value: String(formik.values.variantAxes?.length || 0) },
{ label: 'Assets', value: String(formik.values.assetRequirements?.length || 0) },
{ label: 'Category', value: formik.values.category ? '1' : '0' },
{ label: 'Channels', value: String(formik.values.channels?.length || 0) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span>
<span className="font-bold text-foreground">{value}</span>
</div>
))}
</div>
</div>
</aside>
{/* Content */}
@@ -686,55 +825,215 @@ export default function NewFamily() {
{/* ── Attribute Assignment ── */}
{activeTab === 'attributes' && (
<Card>
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Expand groups to enable/disable optional items." />
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Filter, search, or quick-create attributes inline." />
<div className="p-6 space-y-6">
{/* Row: Attribute Set Select */}
<div className="border-b border-border pb-5">
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
<Select
name="attributeSetId"
value={formik.values.attributeSetId}
onChange={(e) => {
formik.handleChange(e);
const setId = e.target.value;
const selectedSet = attributeSetsList.find(s => s.id === setId);
if (selectedSet && selectedSet.groups) {
const inherited: string[] = [];
for (const g of selectedSet.groups) {
if (g.attributes) {
for (const a of g.attributes) {
inherited.push(a.id || a);
{/* Top Control Panel */}
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 space-y-4 font-sans">
{/* Row 1: Attribute Set & Attribute Group Selectors */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
<div ref={attributeSetDropdownRef} className="relative">
<div
onClick={() => {
if (!isView) {
setIsAttributeSetDropdownOpen(!isAttributeSetDropdownOpen);
}
}
}
formik.setFieldValue('attributes', [...new Set(inherited)]);
} else {
formik.setFieldValue('attributes', []);
}
}}
>
<option value="">Select an Attribute Set...</option>
{attributeSetsList.map((set) => (
<option key={set.id} value={set.id}>{set.name} ({set.code})</option>
))}
</Select>
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
)}
}}
className={`w-full border rounded-lg px-3 py-2 text-sm flex items-center justify-between bg-surface ${
formik.touched.attributeSetId && formik.errors.attributeSetId ? 'border-red-400' : 'border-border'
} ${isView ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'}`}
>
{(() => {
const selectedObj = attributeSetsList.find((s: any) => String(s.id) === String(formik.values.attributeSetId));
return (
<span className={selectedObj?.name ? 'text-foreground font-medium' : 'text-muted-foreground'}>
{selectedObj?.name
? `${selectedObj.name} (${selectedObj.code})`
: 'Select Attribute Set...'}
</span>
);
})()}
<div className="flex items-center gap-1.5">
{formik.values.attributeSetId && !isView && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
formik.setFieldValue('attributeSetId', '');
formik.setFieldValue('attributes', []);
setSelectedGroupFilter('');
setAttributeSearchQuery('');
setBlueprintPreview(null);
}}
className="p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
</div>
</div>
{isAttributeSetDropdownOpen && (
<div className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden flex flex-col max-h-60 animate-in fade-in zoom-in-95 duration-100 font-sans">
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
type="text"
value={attributeSetSearchQuery}
onChange={(e) => setAttributeSetSearchQuery(e.target.value)}
placeholder="Search by name or code..."
autoFocus
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
/>
</div>
<div className="p-2 overflow-y-auto flex-1 space-y-1">
{filteredAttributeSets.length > 0 ? (
filteredAttributeSets.map((s: any) => (
<div
key={s.id}
onClick={() => {
formik.setFieldValue('attributeSetId', s.id);
setSelectedGroupFilter('');
setAttributeSearchQuery('');
if (s.groups) {
const inherited: string[] = [];
for (const g of s.groups) {
if (g.attributes) {
for (const a of g.attributes) {
inherited.push(a.id || a);
}
}
}
formik.setFieldValue('attributes', [...new Set(inherited)]);
} else {
formik.setFieldValue('attributes', []);
}
loadBlueprintPreview(s.id);
setIsAttributeSetDropdownOpen(false);
setAttributeSetSearchQuery('');
}}
className={`px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
formik.values.attributeSetId === s.id
? 'bg-primary/10 text-primary font-bold'
: 'text-foreground hover:bg-background/50'
}`}
>
<div className="font-semibold">{s.name}</div>
<div className="text-[10px] text-muted-foreground font-mono mt-0.5">Code: {s.code}</div>
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">No Attribute Sets found.</div>
)}
</div>
</div>
)}
</div>
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
)}
</div>
<div>
<label className={labelClass}>Attribute Group</label>
<Select
value={selectedGroupFilter}
disabled={!formik.values.attributeSetId || isView}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
>
<option value="">All Attribute Groups</option>
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
return availableGroups.map((g: any) => (
<option key={g.id || g.code} value={g.id || g.code}>{g.name} ({g.code})</option>
));
})()}
</Select>
</div>
</div>
{/* Row 2: Search Input + Quick Create Button */}
<div className="flex items-center gap-3">
<div className="relative flex items-center flex-1">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<input
type="text"
value={attributeSearchQuery}
onChange={(e) => setAttributeSearchQuery(e.target.value)}
placeholder="Search attributes..."
disabled={!formik.values.attributeSetId || isView}
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
/>
{attributeSearchQuery && (
<button
type="button"
onClick={() => setAttributeSearchQuery('')}
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{!isView && (
<button
type="button"
onClick={() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
setInlineAttributeGroupId(availableGroups[0]?.id || '');
setShowAttributeModal(true);
}}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors shrink-0 h-[38px] cursor-pointer"
>
<Plus className="w-4 h-4" /> Create Attribute
</button>
)}
</div>
</div>
{/* Attribute Groups & Attributes List */}
{formik.values.attributeSetId ? (
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2">
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2 font-sans">
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
let groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
if (groupsToRender.length === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No groups found in this Attribute Set.</div>;
// Filter by Attribute Group if selected
if (selectedGroupFilter) {
groupsToRender = groupsToRender.filter((g: any) =>
String(g.id) === String(selectedGroupFilter) || String(g.code) === String(selectedGroupFilter)
);
}
return groupsToRender.map((group: any) => {
if (groupsToRender.length === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No attribute groups match the selected filter.</div>;
}
let totalMatchingAttributes = 0;
const renderedGroups = groupsToRender.map((group: any) => {
const isExpanded = expandedGroups[group.id] !== false; // default true
const groupAttributes = group.attributes || [];
let groupAttributes = group.attributes || [];
// Filter attributes by search query
if (attributeSearchQuery.trim()) {
const q = attributeSearchQuery.toLowerCase().trim();
groupAttributes = groupAttributes.filter((attr: any) =>
(attr.name && attr.name.toLowerCase().includes(q)) ||
(attr.code && attr.code.toLowerCase().includes(q))
);
}
totalMatchingAttributes += groupAttributes.length;
if (attributeSearchQuery.trim() && groupAttributes.length === 0) {
return null; // Skip rendering empty group during active search
}
return (
<div key={group.id} className="border border-border rounded-lg overflow-hidden bg-surface shadow-xs">
@@ -760,13 +1059,12 @@ export default function NewFamily() {
<div className="p-4 grid grid-cols-2 gap-3 bg-surface">
{groupAttributes.map((attr: any) => {
const isChecked = formik.values.attributes.includes(attr.id);
const isRequired = attr.is_required || attr.isRequired;
const isVariant = attr.is_variant_eligible || attr.is_variant_axis || attr.isVariantEligible;
const handleToggle = () => {
if (isView) return;
const current = [...formik.values.attributes];
if (isChecked) {
if (isRequired) return; // Prevent disabling required fields
formik.setFieldValue('attributes', current.filter(id => id !== attr.id));
// Remove from variant axes if deselected
formik.setFieldValue('variantAxes', formik.values.variantAxes.filter(id => id !== attr.id));
@@ -793,7 +1091,6 @@ export default function NewFamily() {
<div className="font-semibold text-sm text-foreground">{attr.name}</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{attr.code} {attr.type?.toUpperCase()}
{isRequired && <span className="ml-2 text-red-500 font-bold">* Required</span>}
{isVariant && <span className="ml-2 text-primary font-medium">(Variant Axis)</span>}
</div>
</div>
@@ -810,11 +1107,17 @@ export default function NewFamily() {
</div>
);
});
if (attributeSearchQuery.trim() && totalMatchingAttributes === 0) {
return <div className="text-center py-8 text-muted-foreground text-sm">No attributes match search "{attributeSearchQuery}".</div>;
}
return renderedGroups;
})()}
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-sm">
Please select an Attribute Set in Step 1 (Basic Details) to view and assign inherited attributes.
Please select an Attribute Set to view, filter, and assign attributes.
</div>
)}
</div>
@@ -888,39 +1191,157 @@ export default function NewFamily() {
{/* ── Asset Families ── */}
{activeTab === 'assets' && (
<Card>
<CardHeader title="Asset Families" subtitle="Select which asset families are assigned to this product family" />
<div className="p-6 space-y-2">
{assetFamiliesList.map((assetFamily) => {
const isChecked = formik.values.assetRequirements.includes(assetFamily.id);
const handleToggle = () => {
const current = [...formik.values.assetRequirements];
if (isChecked) {
formik.setFieldValue('assetRequirements', current.filter(id => id !== assetFamily.id));
} else {
formik.setFieldValue('assetRequirements', [...current, assetFamily.id]);
}
};
return (
<label key={assetFamily.id} onClick={handleToggle} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-border hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={isChecked} readOnly className="w-4 h-4 text-primary rounded border-border focus:ring-primary-light" />
<div>
<div className="font-medium text-sm text-foreground">{assetFamily.name}</div>
{assetFamily.assetTypes && assetFamily.assetTypes.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
Expected Types: {assetFamily.assetTypes.map((t: any) => t.name).join(', ')}
<CardHeader
title="Asset Families Configuration"
subtitle="Search and assign asset families to configure asset requirements for products in this family."
/>
<div className="p-6 space-y-6">
{/* Searchable Asset Family Selector */}
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 font-sans space-y-3">
<label className={labelClass}>Assign Asset Family</label>
<div className="relative">
<div className="relative flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<input
type="text"
value={assetFamilySearchQuery}
onChange={(e) => {
setAssetFamilySearchQuery(e.target.value);
setIsAssetFamilyDropdownOpen(true);
}}
onFocus={() => setIsAssetFamilyDropdownOpen(true)}
placeholder="Search asset family by name or code to assign..."
disabled={isView}
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
/>
{assetFamilySearchQuery && (
<button
type="button"
onClick={() => setAssetFamilySearchQuery('')}
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Dropdown list for search & select */}
{isAssetFamilyDropdownOpen && (
<div
ref={assetFamilyDropdownRef}
className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden max-h-60 overflow-y-auto animate-in fade-in zoom-in-95 duration-100"
>
{filteredUnassignedAssetFamilies.length > 0 ? (
filteredUnassignedAssetFamilies.map((family: any) => (
<div
key={family.id}
onClick={() => {
if (!formik.values.assetRequirements.includes(family.id)) {
formik.setFieldValue('assetRequirements', [...formik.values.assetRequirements, family.id]);
}
setAssetFamilySearchQuery('');
setIsAssetFamilyDropdownOpen(false);
}}
className="px-4 py-3 border-b border-border/50 last:border-b-0 hover:bg-background/80 cursor-pointer transition-colors"
>
<div className="font-semibold text-sm text-foreground">{family.name}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">Code: {family.code}</div>
{family.assetTypes && family.assetTypes.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{family.assetTypes.map((t: any) => (
<span key={t.id || t.name} className="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-[10px] font-medium">
{t.name}
</span>
))}
</div>
)}
</div>
)}
</div>
))
) : (
<div className="p-4 text-center text-xs text-muted-foreground">
{assetFamiliesList.length === 0
? 'No asset families available.'
: 'All matching asset families have been assigned.'}
</div>
)}
</div>
{isChecked && <CheckCircle2 className="w-4 h-4 text-primary" />}
</label>
);
})}
{assetFamiliesList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">No asset families available.</div>
)}
)}
</div>
</div>
{/* Assigned Asset Families List */}
<div className="space-y-3 font-sans">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
Assigned Asset Families ({assignedAssetFamiliesList.length})
</h4>
</div>
{assignedAssetFamiliesList.length > 0 ? (
<div className="space-y-3">
{assignedAssetFamiliesList.map((family: any) => {
const assetTypes = family.assetTypes || [];
return (
<div
key={family.id}
className="flex items-start justify-between p-4 rounded-xl border border-primary/20 bg-primary/5/30 shadow-xs transition-all"
>
<div className="space-y-2 flex-1 mr-4">
<div className="flex items-center gap-2">
<Box className="w-4 h-4 text-primary shrink-0" />
<span className="font-semibold text-sm text-foreground">{family.name}</span>
<span className="text-xs text-muted-foreground font-mono">({family.code})</span>
</div>
{/* Asset Type Classifications associated with this Asset Family */}
<div>
<div className="text-[11px] font-semibold text-muted-foreground mb-1">
Asset Type Classifications ({assetTypes.length}):
</div>
{assetTypes.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{assetTypes.map((type: any) => (
<span
key={type.id || type.name}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-surface border border-border rounded-md text-xs font-medium text-foreground shadow-2xs"
>
<ImageIcon className="w-3 h-3 text-muted-foreground" />
{type.name}
{type.code && <code className="text-[10px] text-muted-foreground">({type.code})</code>}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground italic">No classifications defined for this asset family.</span>
)}
</div>
</div>
{!isView && (
<button
type="button"
onClick={() => {
formik.setFieldValue(
'assetRequirements',
formik.values.assetRequirements.filter((id: string) => id !== family.id)
);
}}
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded-lg transition-colors shrink-0 cursor-pointer"
title="Unassign Asset Family"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
) : (
<div className="p-8 border border-dashed border-border rounded-xl text-center text-sm text-muted-foreground bg-surface-muted/20">
No Asset Families assigned to this Product Family yet. Search and select an Asset Family above to assign.
</div>
)}
</div>
</div>
</Card>
)}
@@ -1053,57 +1474,6 @@ export default function NewFamily() {
</Card>
)}
{/* ── Completeness Rules ── */}
{activeTab === 'rules' && (
<Card>
<CardHeader title="Completeness Rules" subtitle="Define weights that sum up to 100%" />
<div className="p-6 space-y-4">
<div className="bg-primary/5 border border-primary/10 rounded-lg px-4 py-3 flex items-start gap-3">
<AlertCircle className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
<div className="text-sm text-primary-dark">
Configure weights for completeness rules. The sum of all weights must equal exactly 100%. Current sum: {' '}
<strong className="underline">
{Object.values(formik.values.completenessRules).reduce((sum, v) => sum + (Number(v) || 0), 0)}%
</strong>
</div>
</div>
<div className="space-y-3">
{[
{ key: 'required_attributes', name: 'All required attributes filled' },
{ key: 'at_least_one_image', name: 'At least one product image' },
{ key: 'marketing_content', name: 'Marketing content complete' },
{ key: 'tech_specs', name: 'Technical specifications complete' },
{ key: 'skus_assigned', name: 'All variants have SKUs' }
].map((rule) => {
const value = formik.values.completenessRules[rule.key] ?? 0;
const handleWeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Math.max(0, parseInt(e.target.value) || 0);
formik.setFieldValue(`completenessRules.${rule.key}`, val);
};
return (
<div key={rule.key} className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface shadow-xs">
<div className="font-medium text-sm text-foreground">{rule.name}</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="100"
value={value}
onChange={handleWeightChange}
className="w-20 border border-border rounded-lg px-3 py-1.5 text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
/>
<span className="text-sm font-semibold text-muted-foreground">%</span>
</div>
</div>
);
})}
</div>
</div>
</Card>
)}
{/* ── Inheritance Summary ── */}
{activeTab === 'summary' && (
<Card>
@@ -1206,6 +1576,176 @@ export default function NewFamily() {
</div>
</div>
</div>
{/* Quick Create Attribute Modal */}
{showAttributeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs animate-in fade-in duration-150">
<div className="bg-surface rounded-2xl border border-border shadow-2xl p-6 w-full max-w-md animate-in zoom-in-95 duration-200 overflow-y-auto max-h-[90vh] font-sans">
<div className="flex items-center justify-between mb-4 border-b border-border pb-3">
<h3 className="font-bold text-foreground text-base">Quick Create Attribute</h3>
<button
type="button"
onClick={() => setShowAttributeModal(false)}
className="p-1 hover:bg-background rounded text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Name *</label>
<input
type="text"
value={inlineAttributeName}
onChange={(e) => setInlineAttributeName(e.target.value)}
placeholder="e.g. Pack Size"
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
/>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Type *</label>
<Select
value={inlineAttributeType}
onChange={(e) => setInlineAttributeType(e.target.value)}
>
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="select">Select (Dropdown)</option>
<option value="multiselect">Multi-select</option>
<option value="date">Date</option>
</Select>
</div>
{(inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && (
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Options (Comma separated) *</label>
<input
type="text"
value={inlineAttributeOptions}
onChange={(e) => setInlineAttributeOptions(e.target.value)}
placeholder="e.g. Small, Medium, Large"
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
/>
</div>
)}
<div>
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Group</label>
<Select
value={inlineAttributeGroupId}
onChange={(e) => setInlineAttributeGroupId(e.target.value)}
>
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
return availableGroups.map((g: any) => (
<option key={g.id} value={g.id}>{g.name}</option>
));
})()}
</Select>
</div>
<div className="pt-2">
<label className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
<input
type="checkbox"
checked={inlineAttributeRequired}
onChange={(e) => setInlineAttributeRequired(e.target.checked)}
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
/>
Mark as Required
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 border-t border-border pt-4">
<button
type="button"
onClick={() => setShowAttributeModal(false)}
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-foreground hover:bg-background bg-surface transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={
inlineAttributeSubmitting ||
!inlineAttributeName.trim() ||
!inlineAttributeGroupId ||
((inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && !inlineAttributeOptions.trim())
}
onClick={async () => {
setInlineAttributeSubmitting(true);
let createdAttr: any;
try {
const opts = (inlineAttributeType === 'select' || inlineAttributeType === 'multiselect')
? inlineAttributeOptions.split(',').map((o: string) => o.trim()).filter(Boolean)
: undefined;
const attrPayload: any = {
name: inlineAttributeName.trim(),
type: inlineAttributeType,
isRequired: inlineAttributeRequired,
options: opts,
status: 'active'
};
createdAttr = await createAttribute(attrPayload as any);
} catch (err) {
notify.error('Failed to create attribute');
setInlineAttributeSubmitting(false);
return;
}
try {
const newId = createdAttr?.id || (createdAttr as any)?.data?.id;
if (newId && inlineAttributeGroupId) {
const groupDetails = await attributeGroupsService.getById(inlineAttributeGroupId);
const groupData = (groupDetails as any)?.data || groupDetails;
const existingAttributeIds = (groupData?.attributes || []).map((a: any) => {
if (typeof a === 'string') return a;
return a?.id || a?._id;
}).filter(Boolean);
await attributeGroupsService.update(inlineAttributeGroupId, {
attributes: [...existingAttributeIds, newId]
} as any);
// Add new attribute to form attributes list
if (!formik.values.attributes.includes(newId)) {
formik.setFieldValue('attributes', [...formik.values.attributes, newId]);
}
}
fetchAttributes();
fetchAttributeGroups();
if (formik.values.attributeSetId) {
await loadBlueprintPreview(formik.values.attributeSetId);
}
notify.success('Attribute created and added to group successfully');
} catch (assocErr) {
console.error("Failed to associate attribute with group:", assocErr);
} finally {
setInlineAttributeName('');
setInlineAttributeType('text');
setInlineAttributeRequired(false);
setInlineAttributeOptions('');
setShowAttributeModal(false);
setInlineAttributeSubmitting(false);
}
}}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
>
{inlineAttributeSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save
</button>
</div>
</div>
</div>
)}
</div>
</ProtectedRoute>
);
+3 -2
View File
@@ -2,13 +2,14 @@
import { Routes, Route } from 'react-router-dom';
import FamilyList from '../pages/FamilyList';
import NewFamily from '../pages/NewFamily';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const FamilyRoutes = () => {
return (
<Routes>
<Route path="/" element={<FamilyList />} />
<Route path="new" element={<NewFamily />} />
<Route path=":id/edit" element={<NewFamily />} />
<Route path="new" element={<ProtectedRoute node="products.families" action="create"><NewFamily /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="products.families" action="edit"><NewFamily /></ProtectedRoute>} />
<Route path=":id/view" element={<NewFamily />} />
</Routes>
);
@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Copy, KeyRound, Plus, ShieldCheck, Trash2 } from "lucide-react";
import apiClient from "../../../api/axiosInstance";
import { useAppSelector } from "../../../store";
type ApiKeyRecord = {
id: string; name: string; prefix: string; scopes: string[]; status: string;
expiresAt: string; lastUsedAt?: string | null; createdAt: string; apiKey?: string;
};
type ApiResponse<T> = { success: boolean; data: T };
export default function ApiAccessTab() {
const user = useAppSelector((state) => state.auth.user);
const isPlatformUser = user?.type === "platform" || user?.user_type === "platform";
const isSupportMode = isPlatformUser && Boolean(localStorage.getItem("impersonatedTenantId"));
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [name, setName] = useState("");
const [expiresInDays, setExpiresInDays] = useState(90);
const [createdKey, setCreatedKey] = useState<ApiKeyRecord | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = async () => {
const response = await apiClient.get<ApiResponse<ApiKeyRecord[]>>("/api/v1/api-keys");
setKeys(response.data || []);
};
useEffect(() => {
if (!isSupportMode) load().catch((cause: any) => setError(cause?.response?.data?.message || "Could not load API keys."));
}, [isSupportMode]);
if (isSupportMode) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-5 text-sm text-amber-950">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />API-key management is protected in Support Mode</div>
<p className="mt-2">Platform support can inspect tenant data for troubleshooting, but cannot create, view, or revoke credentials on the tenant's behalf.</p>
<p className="mt-2">A real tenant administrator must sign in to this workspace and open <strong>Integration Hub API Access</strong> to manage product API keys.</p>
</div>
);
}
const createKey = async () => {
setBusy(true); setError("");
try {
const response = await apiClient.post<ApiResponse<ApiKeyRecord>>("/api/v1/api-keys", { name, expiresInDays });
setCreatedKey(response.data); setName(""); await load();
} catch (cause: any) {
setError(cause?.response?.data?.message || "Could not create API key.");
} finally { setBusy(false); }
};
const revoke = async (id: string) => {
if (!window.confirm("Revoke this API key? Applications using it will immediately lose access.")) return;
await apiClient.delete(`/api/v1/api-keys/${id}`); await load();
};
return (
<div className="space-y-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<div className="flex items-center gap-2 font-semibold"><ShieldCheck className="h-4 w-4" />What this key can do</div>
<p className="mt-2">It can only read products belonging to your tenant through <code>GET /api/v1/external/products</code>. It cannot create, edit, delete, publish, or access another tenant.</p>
</div>
<div className="rounded-lg border border-border p-4">
<h3 className="font-semibold text-foreground">Create a read-only product key</h3>
<div className="mt-3 flex flex-wrap gap-3">
<input aria-label="API key name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Example: Website product reader" maxLength={120} className="min-w-64 rounded-lg border border-border bg-surface px-3 py-2 text-sm" />
<select aria-label="API key expiry" value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} className="rounded-lg border border-border bg-surface px-3 py-2 text-sm">
<option value={30}>30 days</option><option value={90}>90 days</option><option value={180}>180 days</option><option value={365}>365 days</option>
</select>
<button disabled={busy || !name.trim()} onClick={createKey} className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"><Plus className="h-4 w-4" />Create key</button>
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
{createdKey?.apiKey && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
<p className="font-semibold text-amber-950">Copy this key now it will never be shown again.</p>
<div className="mt-3 flex gap-2"><code className="min-w-0 flex-1 overflow-x-auto rounded bg-white p-3 text-sm">{createdKey.apiKey}</code><button aria-label="Copy API key" onClick={() => navigator.clipboard.writeText(createdKey.apiKey!)} className="rounded-lg border border-amber-300 bg-white px-3"><Copy className="h-4 w-4" /></button></div>
<button onClick={() => setCreatedKey(null)} className="mt-3 text-sm font-medium text-amber-900 underline">I have saved it securely</button>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-sm"><thead className="bg-surface-muted"><tr><th className="p-3">Name</th><th className="p-3">Prefix</th><th className="p-3">Scope</th><th className="p-3">Expires</th><th className="p-3">Last used</th><th className="p-3">Status</th><th className="p-3"></th></tr></thead>
<tbody>{keys.map((key) => <tr key={key.id} className="border-t border-border"><td className="p-3 font-medium"><span className="inline-flex items-center gap-2"><KeyRound className="h-4 w-4" />{key.name}</span></td><td className="p-3 font-mono text-xs">{key.prefix}</td><td className="p-3">products:read</td><td className="p-3">{new Date(key.expiresAt).toLocaleDateString()}</td><td className="p-3">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td><td className="p-3 capitalize">{key.status}</td><td className="p-3 text-right">{key.status === "active" && <button aria-label={`Revoke ${key.name}`} onClick={() => revoke(key.id)} className="text-red-600"><Trash2 className="h-4 w-4" /></button>}</td></tr>)}</tbody>
</table>
{keys.length === 0 && <p className="p-6 text-center text-muted-foreground">No API keys yet.</p>}
</div>
<div className="rounded-lg border border-border p-4 text-sm"><h3 className="font-semibold">Example request</h3><pre className="mt-2 overflow-x-auto rounded bg-slate-950 p-3 text-slate-100">{`curl https://YOUR-PIM/api/v1/external/products \\\n -H "X-API-Key: pim_live_your_key"`}</pre></div>
</div>
);
}
@@ -1,115 +1,19 @@
import { useState } from "react";
import { Download, Filter, Clock } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Clock, RefreshCw } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { SearchBar } from "../../../components/customs/SearchBar";
const MOCK_LOGS = [
{
time: "2025-06-09 14:35",
event: "Started Sync",
integration: "Amazon India",
description: "Manual full catalogue sync initiated — 4,240 products by Sarah Chen",
user: "Sarah Chen"
},
{
time: "2025-06-09 12:00",
event: "Scheduled Sync",
integration: "Retail POS Network",
description: "Daily catalogue sync triggered by schedule",
user: "System"
},
{
time: "2025-06-08 16:22",
event: "Updated Credentials",
integration: "Shopify Main Store",
description: "Access token rotated. Previous token expires 2025-07-01",
user: "Michael Torres"
},
{
time: "2025-06-08 09:00",
event: "Created Integration",
integration: "WooCommerce EU",
description: "New integration created in staging environment",
user: "Admin User"
},
];
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function AuditLogsList() {
const [searchQuery, setSearchQuery] = useState("");
// Optional: Filter logs based on search
const filteredLogs = MOCK_LOGS.filter(log =>
log.event.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.integration.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6 pb-6">
{/* SearchBar + Action Buttons */}
<div className="flex items-center gap-3">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search logs..."
className="flex-1"
/>
<Button variant="outline" className="border-border bg-surface px-4">
<Filter className="w-4 h-4" />
</Button>
<Button variant="outline" className="border-border bg-surface px-4">
<Download className="w-4 h-4" /> Export
</Button>
</div>
{/* Vertical Timeline */}
<div className="bg-surface rounded-2xl border border-border shadow-sm overflow-hidden">
<div className="divide-y divide-border">
{filteredLogs.length > 0 ? (
filteredLogs.map((log, index) => (
<div key={index} className="p-6 flex gap-5 hover:bg-background transition-colors group">
{/* Purple Dot */}
<div className="mt-1.5 w-3 h-3 rounded-full bg-primary flex-shrink-0 ring-4 ring-primary/10" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="font-semibold text-foreground text-[15px]">{log.event}</div>
<span className="px-3 py-0.5 text-xs font-medium bg-primary-light text-primary-dark rounded-full">
{log.integration}
</span>
</div>
<div className="mt-2 text-sm text-muted-foreground leading-relaxed">
{log.description}
</div>
<div className="mt-4 flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{log.time}
</div>
<span className="text-muted-foreground"></span>
<span>by {log.user}</span>
</div>
</div>
</div>
))
) : (
<div className="p-12 text-center text-muted-foreground">
No matching logs found.
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-5 border-t border-border bg-background text-center">
<button className="text-sm text-primary font-medium hover:text-primary-dark flex items-center gap-1.5 mx-auto">
Load More Activity
<span className="text-base leading-none"></span>
</button>
</div>
</div>
const [logs, setLogs] = useState<any[]>([]); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { setLoading(true); try { setLogs(await channelsApi.getOperationsAudit()); } catch { notify.error("Unable to load operations audit"); } finally { setLoading(false); } }, []);
useEffect(() => { load(); }, [load]);
const shown = logs.filter(log => `${log.action} ${log.resource} ${JSON.stringify(log.details || {})}`.toLowerCase().includes(search.toLowerCase()));
return <div className="space-y-4">
<div className="flex gap-3"><SearchBar value={search} onChange={setSearch} placeholder="Search audit events..." className="flex-1"/><Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh</Button></div>
<div className="border border-border rounded-xl divide-y divide-border bg-surface">
{shown.length ? shown.map(log => <div key={log.id} className="p-5 flex gap-4"><div className="mt-1.5 w-2.5 h-2.5 rounded-full bg-primary shrink-0"/><div><div className="font-semibold">{log.action}</div><div className="text-sm text-muted-foreground mt-1">{log.resource}{log.resource_id ? ` · ${log.resource_id}` : ""}</div><div className="flex items-center gap-1 text-xs text-muted-foreground mt-2"><Clock className="w-3.5 h-3.5"/>{new Date(log.createdAt || log.created_at).toLocaleString()}</div></div></div>) : <div className="p-10 text-center text-sm text-muted-foreground">No tenant-owned channel or integration audit events found.</div>}
</div>
);
</div>;
}
@@ -1,171 +1,23 @@
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge, type BadgeVariant } from "../../../components/customs/StatusBadge";
const MOCK_ERRORS = [
{
id: "1",
product: "Samsung Galaxy S25 Ultra",
variant: "256GB Black",
sku: "P-8841",
integration: "Amazon India",
errorType: "Validation Error",
message: "Required attribute 'bullet_points' missing for Amazon listing compliance",
date: "2025-06-09 14:32",
severity: "High",
status: "Open"
},
{
id: "2",
product: "Sony WH-1000XM5",
variant: "Black",
sku: "P-6621",
integration: "Amazon India",
errorType: "Image Rejected",
message: "Primary image does not meet Amazon image guidelines (minimum 1000px)",
date: "2025-06-09 14:28",
severity: "High",
status: "Open"
},
{
id: "3",
product: "Nike Air Max 270",
variant: "UK 8 White",
sku: "P-4412",
integration: "Shopify Main Store",
errorType: "SKU Conflict",
message: "SKU 'NAM270-W8' already exists in Shopify with different product ID",
date: "2025-06-09 14:01",
severity: "Critical",
status: "Retrying"
},
{
id: "4",
product: "Adidas Ultraboost 24",
variant: "UK 9 Blue",
sku: "P-4520",
integration: "Shopify Main Store",
errorType: "Rate Limit",
message: "Shopify API rate limit exceeded (40/s). Request queued for retry.",
date: "2025-06-09 13:58",
severity: "Medium",
status: "Resolved"
},
{
id: "5",
product: "WMS Inventory Batch",
variant: "-",
sku: "BATCH-284",
integration: "Warehouse WMS",
errorType: "Connection Timeout",
message: "Connection to warehouse endpoint timed out after 30s. Host: wms.internal:8080",
date: "2025-06-08 08:03",
severity: "Critical",
status: "Open"
},
];
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { Button } from "../../../components/customs/Button";
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function ErrorCenterList() {
const navigate = useNavigate();
const [items, setItems] = useState<any[]>([]); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { setLoading(true); try { setItems(await channelsApi.getErrors()); } catch { notify.error("Unable to load syndication errors"); } finally { setLoading(false); } }, []);
useEffect(() => { load(); }, [load]);
const columns = [
{
key: "product",
label: "PRODUCT",
render: (_: any, row: any) => (
<div>
<div className="font-medium text-foreground">{row.product}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.sku}</div>
</div>
),
},
{ key: "variant", label: "VARIANT" },
{ key: "integration", label: "INTEGRATION" },
{
key: "errorType",
label: "ERROR TYPE",
render: (val: string) => (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="font-medium text-foreground">{val}</span>
</div>
),
},
{
key: "message",
label: "MESSAGE",
render: (val: string) => <div className="text-sm text-muted-foreground line-clamp-2 max-w-md">{val}</div>
},
{ key: "date", label: "DATE" },
{
key: "severity",
label: "SEVERITY",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Critical") variant = "error";
if (val === "High") variant = "warning";
if (val === "Medium") variant = "approval";
return <StatusBadge status={variant} label={val} />;
},
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
let variant: BadgeVariant = "neutral";
if (val === "Open") variant = "error"; // red
if (val === "Retrying") variant = "warning"; // orange
if (val === "Resolved") variant = "success"; // green
return <StatusBadge status={variant} label={val} />;
},
},
{ key: "product", label: "PRODUCT", render: (_: any, r: any) => <div><div className="font-medium">{r.product?.name || "Unknown product"}</div><div className="text-xs text-muted-foreground">{r.product?.code || r.product_id}</div></div> },
{ key: "channel", label: "CHANNEL", render: (_: any, r: any) => r.channel?.name || "—" },
{ key: "error_code", label: "ERROR", render: (v: string) => <span className="font-mono text-xs">{v || "UNKNOWN"}</span> },
{ key: "error_message", label: "MESSAGE" },
{ key: "attempt_count", label: "ATTEMPTS" },
{ key: "status", label: "STATUS", render: (v: string) => <StatusBadge status={v === "retrying" ? "warning" : "error"} label={v} /> },
{ key: "updated_at", label: "UPDATED", render: (v: string) => new Date(v).toLocaleString() }
];
return (
<div className="space-y-6">
{/* Summary Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-center gap-4">
<div className="p-3 bg-surface rounded-lg">
<AlertTriangle className="w-6 h-6 text-amber-600" />
</div>
<div>
<div className="font-semibold text-amber-900">5 Active Errors 3 require immediate attention</div>
<div className="text-sm text-amber-700 mt-0.5">Review and resolve to maintain sync health</div>
</div>
</div>
<DataTable
columns={columns}
data={MOCK_ERRORS}
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
}}
searchPlaceholder="Search errors..."
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">
<option>All Integrations</option>
<option>Amazon India</option>
<option>Shopify Main Store</option>
</select>
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option>All Severities</option>
<option>Critical</option>
<option>High</option>
<option>Medium</option>
</select>
<select className="h-9 px-3 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option>All Statuses</option>
<option>Open</option>
<option>Retrying</option>
<option>Resolved</option>
</select>
</div>
}
/>
</div>
);
return <DataTable columns={columns} data={items} rowIdKey="id" resultLabel="errors" searchPlaceholder="Search errors..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Errors</Button>} />;
}
@@ -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>
);
};
@@ -1,169 +1,25 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Download } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
const MOCK_RULES = [
{
id: "1",
name: "Electronics to Amazon",
integration: "Amazon India",
family: "Consumer Electronics",
category: "All Categories",
workflowState: "Approved",
products: 2840,
status: "active",
lastEvaluated: "2025-06-09 14:32"
},
{
id: "2",
name: "Fashion to Shopify",
integration: "Shopify Main Store",
family: "Apparel & Fashion",
category: "Clothing",
workflowState: "Published",
products: 5120,
status: "active",
lastEvaluated: "2025-06-09 14:00"
},
{
id: "3",
name: "Retail Products to POS",
integration: "Retail POS Network",
family: "All Families",
category: "All Categories",
workflowState: "Approved",
products: 3240,
status: "active",
lastEvaluated: "2025-06-09 12:00"
},
{
id: "4",
name: "Warehouse Items to WMS",
integration: "Warehouse WMS",
family: "All Families",
category: "All Categories",
workflowState: "Any",
products: 0,
status: "inactive",
lastEvaluated: "2025-06-08 08:00"
},
{
id: "5",
name: "UAE Electronics",
integration: "Amazon UAE",
family: "Consumer Electronics",
category: "Smartphones",
workflowState: "Published",
products: 840,
status: "active",
lastEvaluated: "2025-06-09 13:15"
},
];
import { channelsApi } from "../../channels/api/channels.api";
import { notify } from "../../../services/toast";
export default function PublishingRulesList() {
const navigate = useNavigate();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [rows, setRows] = useState<any[]>([]); const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const channels = await channelsApi.getAll();
const mappings = await Promise.all(channels.map(async channel => ({ channel, mappings: await channelsApi.getMappings(channel.id) })));
setRows(mappings.map(({ channel, mappings }) => ({ id: channel.id, name: channel.name, code: channel.code, status: channel.status, mappedFields: mappings.length, requiredFields: mappings.filter((m:any) => m.is_required).length, transformations: [...new Set(mappings.map((m:any) => m.transformation_rule).filter((v:string) => v && v !== 'none'))].join(', ') || 'None' })));
} catch { notify.error("Unable to load channel mapping rules"); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const columns = [
{
key: "name",
label: "RULE NAME",
sortable: true,
render: (val: string, row: any) => (
<div>
<div className="font-semibold text-foreground">{val}</div>
<div className="text-xs text-muted-foreground mt-0.5">{row.integration}</div>
</div>
),
},
{ key: "family", label: "FAMILY" },
{ key: "category", label: "CATEGORY" },
{
key: "workflowState",
label: "WORKFLOW STATE",
render: (val: string) => {
let variant: any = "neutral";
let label = val;
if (val === "Approved") variant = "success";
if (val === "Published") variant = "published";
if (val === "Any") variant = "warning";
return <StatusBadge status={variant} label={label} />;
},
},
{
key: "products",
label: "PRODUCTS",
render: (val: number) => <span className="font-semibold text-foreground">{val.toLocaleString()}</span>
},
{
key: "status",
label: "STATUS",
render: (val: string) => {
const variant = val === "active" ? "active" : "error"; // active = green, inactive = red
const label = val === "active" ? "Active" : "Inactive";
return <StatusBadge status={variant} label={label} />;
},
},
{ key: "lastEvaluated", label: "LAST EVALUATED" },
{ key: "name", label: "CHANNEL" }, { key: "code", label: "CODE" }, { key: "mappedFields", label: "MAPPED FIELDS" },
{ key: "requiredFields", label: "REQUIRED" }, { key: "transformations", label: "TRANSFORMATIONS" }, { key: "status", label: "STATUS" }
];
const handleDeleteConfirm = () => {
console.log("Deleted rule:", deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
};
return (
<div className="space-y-6">
<DataTable
columns={columns}
data={MOCK_RULES}
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 rules..."
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 Integrations</option>
<option>Amazon India</option>
<option>Shopify Main Store</option>
<option>Warehouse WMS</option>
</select>
</div>
}
toolbarRight={
<div className="flex gap-2">
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Rules
</Button>
<Button
className="bg-primary hover:bg-primary-hover text-white"
onClick={() => navigate("/integrations/new-rule")}
>
<Plus className="w-4 h-4 mr-2" />
Create New Rule
</Button>
</div>
}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Publishing Rule"
description="Are you sure you want to delete this publishing rule? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</div>
);
return <DataTable columns={columns} data={rows} rowIdKey="id" resultLabel="channel rule sets" searchPlaceholder="Search channel rules..." toolbarLeft={<Button variant="outline" onClick={load} disabled={loading}><RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`}/>Refresh Rules</Button>} />;
}
@@ -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,31 +9,40 @@ 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";
import type { Integration } from "../types/integrations.types";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { StatsCard } from "../../../components/customs/StatsCard";
import { channelsApi } from "../../channels/api/channels.api";
// 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";
// ──────────────────────────────────────────────
import ApiAccessTab from "../components/ApiAccessTab";
const INTEGRATION_META: Record<string, { label: string; icon: any; color: string; bg: string }> = {
shopify: { label: "Shopify", icon: ShoppingCart, color: "text-green-600", bg: "bg-green-50" },
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" },
erp: { label: "ERP", icon: Server, color: "text-red-600", bg: "bg-red-50" },
@@ -47,21 +56,40 @@ 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(() => {
fetchItems();
fetchChannels();
Promise.all([channelsApi.getAllJobs(), channelsApi.getErrors()])
.then(([jobs, errors]) => setOperations({ jobs, errors }))
.catch(() => setOperations({ jobs: [], errors: [] }));
}, [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 +103,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 +142,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 +153,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 +233,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 +241,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>
);
}
+123 -200
View File
@@ -3,9 +3,8 @@ import { useNavigate, useParams, useLocation } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
Zap, ShoppingCart, ShoppingBag, Server, Warehouse, Check,
Store, Globe, Smartphone, Monitor, Code2, Eye, EyeOff, Settings2,
Calendar, Clock, Wifi, ChevronDown, Info
Zap, ShoppingCart, Check, Code2, Eye, EyeOff, Settings2,
Info
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
@@ -23,21 +22,13 @@ const STEPS = [
];
const INTEGRATION_TYPES = [
{ id: "ecommerce", label: "E-Commerce", sub: "Shopify, WooCommerce, Magento", icon: ShoppingCart },
{ id: "marketplace", label: "Marketplace", sub: "Amazon, eBay, Flipkart, Noon", icon: ShoppingBag },
{ id: "erp", label: "ERP", sub: "SAP, Oracle, Microsoft Dynamics", icon: Server },
{ id: "wms", label: "WMS", sub: "Warehouse management systems", icon: Warehouse },
{ id: "pos", label: "POS", sub: "Retail point-of-sale systems", icon: Store },
{ id: "b2b_portal", label: "B2B Portal", sub: "B2B client storefronts and portals", icon: Globe },
{ id: "mobile_app", label: "Mobile App", sub: "iOS/Android application platforms", icon: Smartphone },
{ id: "website", label: "Website", sub: "Marketing and catalog websites", icon: Monitor },
{ id: "custom_api", label: "Custom API", sub: "Any REST or GraphQL endpoint", icon: Code2 },
{ id: "custom_api", label: "Generic REST API", sub: "Send mapped products to any HTTPS website or application", icon: Code2 },
{ id: "shopify", label: "Shopify", sub: "Separate Shopify credential flow; publishing is the next phase", icon: ShoppingCart },
];
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency, disabled }: {
function IntegrationSummary({ name, channel, channelName, type, environment, syncDirection, syncFrequency }: {
name: string; channel: string; channelName: string; type: string;
environment: string; syncDirection: string; syncFrequency: string;
disabled?: boolean;
}) {
return (
<div className="w-60 shrink-0">
@@ -75,9 +66,9 @@ function IntegrationSummary({ name, channel, channelName, type, environment, syn
</div>
</div>
<div className="mt-5 pt-5 border-t border-border">
<button type="button" disabled={disabled} className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<button type="button" disabled className="w-full flex items-center justify-center gap-2 py-2.5 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
<Zap className="w-4 h-4" />
Test Connection
Use Test Connection above
</button>
</div>
</div>
@@ -96,6 +87,12 @@ const integrationSchema = Yup.object({
syncFrequency: Yup.string().required(),
autoRetry: Yup.boolean(),
retryAttempts: Yup.number().min(1).max(10),
storeUrl: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Permanent .myshopify.com domain is required').matches(/^https:\/\/[a-z0-9][a-z0-9-]*\.myshopify\.com\/?$/i, 'Use https://your-store.myshopify.com') }),
clientId: Yup.string().when('integrationType', { is: 'shopify', then: schema => schema.required('Shopify Client ID is required') }),
clientSecret: Yup.string(),
endpoint: Yup.string().when('integrationType', { is: 'custom_api', then: schema => schema.required('Delivery endpoint is required').url('Enter a complete HTTPS URL').matches(/^https:\/\//i, 'External endpoints must use HTTPS') }),
testEndpoint: Yup.string().url('Enter a complete URL').matches(/^(https:\/\/|$)/i, 'External endpoints must use HTTPS'),
customApiHeaderName: Yup.string().matches(/^[A-Za-z0-9-]*$/, 'Header names may contain letters, numbers and hyphens only'),
});
export default function NewIntegration() {
@@ -107,6 +104,8 @@ export default function NewIntegration() {
const [activeStep, setActiveStep] = useState('general');
const [showToken, setShowToken] = useState(false);
const [loadingItem, setLoadingItem] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [connectionMessage, setConnectionMessage] = useState("");
const { createItem, updateItem } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
@@ -119,17 +118,17 @@ export default function NewIntegration() {
initialValues: {
name: "",
channel: "",
integrationType: "ecommerce",
integrationType: "custom_api",
environment: "production",
status: "Connected",
description: "",
storeUrl: "",
accessToken: "",
apiVersion: "2025-01",
apiVersion: "2026-07",
webhookSecret: "",
shopIdentifier: "",
syncDirection: "pim_to_channel",
syncFrequency: "hourly",
syncFrequency: "manual",
autoRetry: true,
retryAttempts: 3,
@@ -142,7 +141,7 @@ export default function NewIntegration() {
clientSecret: "",
// ERP/WMS specific
authMethod: "bearer",
authMethod: "apikey",
authToken: "",
username: "",
password: "",
@@ -159,17 +158,35 @@ export default function NewIntegration() {
gatewayUrl: "",
// Custom API specific
customApiUrl: "",
customApiHeaderKey: "",
endpoint: "",
testEndpoint: "",
method: "POST",
customApiHeaderName: "X-API-Key",
customApiHeaderValue: "",
},
validationSchema: integrationSchema,
onSubmit: async (values, { setSubmitting }) => {
onSubmit: async (values, { setSubmitting, setFieldError }) => {
if (!isEdit && values.integrationType === 'shopify' && !values.clientSecret) {
setFieldError('clientSecret', 'Shopify Client Secret is required');
setActiveStep('connection');
setSubmitting(false);
return;
}
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'bearer' && !values.authToken) {
setFieldError('authToken', 'Bearer token is required'); setActiveStep('connection'); setSubmitting(false); return;
}
if (!isEdit && values.integrationType === 'custom_api' && values.authMethod === 'apikey' && !values.customApiHeaderValue) {
setFieldError('customApiHeaderValue', 'API key value is required'); setActiveStep('connection'); setSubmitting(false); return;
}
const submission = values.integrationType === 'custom_api' ? {
...values,
clearSecretFields: values.authMethod === 'none' ? ['authToken', 'customApiHeaderValue'] : values.authMethod === 'bearer' ? ['customApiHeaderValue'] : ['authToken']
} : values;
try {
if (isEdit && id) {
await updateItem(id, values as any);
await updateItem(id, submission as any);
} else {
await createItem(values as any);
await createItem(submission as any);
}
navigate("..");
} catch {
@@ -194,7 +211,7 @@ export default function NewIntegration() {
description: item.description || "",
storeUrl: item.storeUrl || "",
accessToken: item.accessToken || "",
apiVersion: item.apiVersion || "2025-01",
apiVersion: item.apiVersion || "2026-07",
webhookSecret: item.webhookSecret || "",
shopIdentifier: item.shopIdentifier || "",
syncDirection: item.syncDirection,
@@ -220,8 +237,10 @@ export default function NewIntegration() {
appId: item.appId || "",
bundleIdentifier: item.bundleIdentifier || "",
gatewayUrl: item.gatewayUrl || "",
customApiUrl: item.customApiUrl || "",
customApiHeaderKey: item.customApiHeaderKey || "",
endpoint: item.endpoint || item.customApiUrl || "",
testEndpoint: item.testEndpoint || "",
method: item.method || "POST",
customApiHeaderName: item.customApiHeaderName || item.customApiHeaderKey || "X-API-Key",
customApiHeaderValue: item.customApiHeaderValue || "",
});
}
@@ -233,11 +252,19 @@ export default function NewIntegration() {
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
formik.setFieldValue("channel", val);
// Auto-match integrationType to channelType from Channel Registry
const selectedChan = channels.find(c => c.code === val || c.id === val);
if (selectedChan && selectedChan.channelType) {
formik.setFieldValue("integrationType", selectedChan.channelType);
};
const handleTestConnection = async () => {
if (!id) return;
setTestingConnection(true);
setConnectionMessage("");
try {
const result = await integrationsService.testConnection(id);
setConnectionMessage(result.message);
} catch (error: any) {
setConnectionMessage(error?.response?.data?.message || "Connection test failed");
} finally {
setTestingConnection(false);
}
};
@@ -283,10 +310,12 @@ export default function NewIntegration() {
{!isView && (
<button
type="button"
onClick={handleTestConnection}
disabled={!id || testingConnection}
className="flex items-center gap-2 py-2 px-4 border border-warning/20 bg-warning/10 text-warning rounded-lg text-sm font-medium hover:bg-warning/20 transition-colors"
>
<Zap className="w-4 h-4" />
Test Connection
{testingConnection ? "Testing…" : id ? "Test Connection" : "Save Before Testing"}
</button>
)}
<Button variant="outline" type="button" onClick={() => navigate('..')}>
@@ -344,6 +373,18 @@ export default function NewIntegration() {
<CardHeader title="General Information" subtitle="Name, channel, environment and status" />
<div className="p-6 space-y-5">
<div>
<label className={labelClass}>How will products leave the PIM? *</label>
<div className="grid grid-cols-2 gap-3">
{INTEGRATION_TYPES.map((type) => {
const Icon = type.icon;
const selected = formik.values.integrationType === type.id;
return <button key={type.id} type="button" onClick={() => formik.setFieldValue('integrationType', type.id)} className={`rounded-xl border-2 p-4 text-left transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}><div className="flex items-center gap-2"><Icon className={`h-5 w-5 ${selected ? 'text-primary' : 'text-muted-foreground'}`} /><span className="font-semibold">{type.label}</span></div><p className="mt-2 text-xs text-muted-foreground">{type.sub}</p></button>;
})}
</div>
<div className="mt-3 rounded-lg bg-surface-muted p-3 text-xs text-muted-foreground"><strong>Channel</strong> decides which fields and transformations are sent. <strong>Integration</strong> stores where to send them and how the destination authenticates PIM.</div>
</div>
<div>
<label className={labelClass}>Integration Name *</label>
<input
@@ -351,7 +392,7 @@ export default function NewIntegration() {
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Amazon India Production"
placeholder="e.g. Main Website REST API"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && (
@@ -371,7 +412,7 @@ export default function NewIntegration() {
>
<option value="">Select a channel from Channel Registry...</option>
{channels.map((chan) => (
<option key={chan.id} value={chan.code || chan.id}>
<option key={chan.id} value={chan.id}>
{chan.name} ({chan.code})
</option>
))}
@@ -396,21 +437,7 @@ export default function NewIntegration() {
</select>
</div>
</div>
<div>
<label className={labelClass}>Status</label>
<div className="relative">
<select
name="status"
value={formik.values.status}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="Connected">Connected</option>
<option value="Pending">Pending</option>
<option value="Disconnected">Disconnected</option>
</select>
</div>
</div>
<div><label className={labelClass}>Connection Status</label><div className="rounded-lg border border-border bg-surface-muted px-3 py-2.5 text-sm">Pending until credentials are tested successfully</div></div>
</div>
<div>
@@ -429,8 +456,8 @@ export default function NewIntegration() {
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-700">
<Info className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<p>
{formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal" ? (
"Provide your platform URL, access credentials, and store configuration identifiers."
{formik.values.integrationType === "shopify" ? (
"Enter the permanent .myshopify.com domain plus the Client ID and Client Secret from Shopify Dev Dashboard → Apps → your app → Settings. The PIM obtains short-lived access tokens automatically."
) : formik.values.integrationType === "marketplace" ? (
"Marketplace connection requires Seller ID, Marketplace ID, and Selling Partner API credentials."
) : formik.values.integrationType === "erp" || formik.values.integrationType === "wms" ? (
@@ -440,16 +467,16 @@ export default function NewIntegration() {
) : formik.values.integrationType === "mobile_app" ? (
"Enter application identifiers, bundle settings, and push sync settings."
) : (
"Set up custom REST or GraphQL webhook integrations using key-value headers."
"Your website or application must provide an HTTPS product endpoint and tell you how it authenticates requests. The PIM will send mapped product JSON to that endpoint; it does not host or create the destination API."
)}
</p>
</div>
{/* Ecommerce / Website / B2B Portal Fields */}
{(formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal") && (
{/* Shopify Fields */}
{formik.values.integrationType === "shopify" && (
<>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Store / Website URL *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Permanent Shopify Store Domain *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
@@ -457,18 +484,25 @@ export default function NewIntegration() {
placeholder="https://your-store.myshopify.com"
className={inputClass()}
/>
<p className="mt-1 text-xs text-muted-foreground">Use the permanent <strong>.myshopify.com</strong> domainnot the storefront domain and not the PIM URL.</p>
{formik.touched.storeUrl && formik.errors.storeUrl && <p className="mt-1 text-xs text-red-500">{formik.errors.storeUrl}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Access Token *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client ID *</label>
<input name="clientId" value={formik.values.clientId} onChange={formik.handleChange} placeholder="Dev Dashboard → App → Settings" className={inputClass()} />
{formik.touched.clientId && formik.errors.clientId && <p className="mt-1 text-xs text-red-500">{formik.errors.clientId}</p>}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Shopify Client Secret *</label>
<div className="relative">
<input
name="accessToken"
name="clientSecret"
type={showToken ? "text" : "password"}
value={formik.values.accessToken}
value={formik.values.clientSecret}
onChange={formik.handleChange}
placeholder="shpat_xxxxxxxxxxx"
placeholder="Dev Dashboard → App → Settings"
className={inputClass() + " pr-10"}
/>
<button
@@ -479,44 +513,11 @@ export default function NewIntegration() {
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">API Version / Edition</label>
<select
name="apiVersion"
value={formik.values.apiVersion}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="2025-01">2025-01 (Latest)</option>
<option value="2024-10">2024-10</option>
<option value="2024-07">2024-07</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Webhook Secret Key</label>
<input
name="webhookSecret"
value={formik.values.webhookSecret}
onChange={formik.handleChange}
placeholder="Optional webhook secret"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Shop Identifier</label>
<input
name="shopIdentifier"
value={formik.values.shopIdentifier}
onChange={formik.handleChange}
placeholder="e.g., store_main"
className={inputClass()}
/>
{formik.errors.clientSecret && <p className="mt-1 text-xs text-red-500">{formik.errors.clientSecret}</p>}
</div>
</div>
<div className="rounded-lg border border-border bg-surface-muted p-3 text-sm"><strong>Admin API version:</strong> 2026-07, managed by the PIM. No access token, separate webhook secret, or shop identifier is required for the first connection test.</div>
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
</>
)}
@@ -797,40 +798,38 @@ export default function NewIntegration() {
{/* Custom API Fields */}
{formik.values.integrationType === "custom_api" && (
<>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<p className="font-semibold">Before filling this form, ask your website developer for:</p>
<ol className="mt-2 list-decimal space-y-1 pl-5"><li>The HTTPS URL that accepts one product as JSON.</li><li>Whether it needs no authentication, a Bearer token, or an API-key header.</li><li>A safe test URL if testing must not create a real product.</li></ol>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Base API Gateway URL *</label>
<label className="block text-sm font-medium text-foreground mb-1.5">Product Delivery URL *</label>
<input
name="customApiUrl"
value={formik.values.customApiUrl}
name="endpoint"
value={formik.values.endpoint}
onChange={formik.handleChange}
placeholder="https://api.externalpartner.com/v2"
onBlur={formik.handleBlur}
placeholder="https://your-site.com/api/pim/products"
className={inputClass()}
/>
<p className="mt-1 text-xs text-muted-foreground">PIM sends one mapped product to this URL during a syndication job.</p>
{formik.touched.endpoint && formik.errors.endpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.endpoint}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Name</label>
<input
name="customApiHeaderKey"
value={formik.values.customApiHeaderKey}
onChange={formik.handleChange}
placeholder="X-Partner-Token"
className={inputClass()}
/>
<label className={labelClass}>HTTP Method</label>
<select name="method" value={formik.values.method} onChange={formik.handleChange} className={inputClass()}><option value="POST">POST create/upsert</option><option value="PUT">PUT replace/upsert</option><option value="PATCH">PATCH partial update</option></select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">Custom Header Value</label>
<input
name="customApiHeaderValue"
type="password"
value={formik.values.customApiHeaderValue}
onChange={formik.handleChange}
placeholder="token-value"
className={inputClass()}
/>
<label className={labelClass}>Authentication</label>
<select name="authMethod" value={formik.values.authMethod} onChange={formik.handleChange} className={inputClass()}><option value="apikey">API-key header</option><option value="bearer">Bearer token</option><option value="none">No authentication</option></select>
</div>
</div>
{formik.values.authMethod === 'apikey' && <div className="grid grid-cols-2 gap-4"><div><label className={labelClass}>API-key Header Name *</label><input name="customApiHeaderName" value={formik.values.customApiHeaderName} onChange={formik.handleChange} placeholder="X-API-Key" className={inputClass()} />{formik.errors.customApiHeaderName && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderName}</p>}</div><div><label className={labelClass}>API-key Value *</label><input name="customApiHeaderValue" type={showToken ? 'text' : 'password'} value={formik.values.customApiHeaderValue} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current secret' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.customApiHeaderValue && <p className="mt-1 text-xs text-red-500">{formik.errors.customApiHeaderValue}</p>}</div></div>}
{formik.values.authMethod === 'bearer' && <div><label className={labelClass}>Bearer Token *</label><input name="authToken" type={showToken ? 'text' : 'password'} value={formik.values.authToken} onChange={formik.handleChange} placeholder={isEdit ? 'Leave blank to keep current token' : 'Provided by your website developer'} className={inputClass()} />{formik.errors.authToken && <p className="mt-1 text-xs text-red-500">{formik.errors.authToken}</p>}</div>}
<div><label className={labelClass}>Safe Connection-Test URL <span className="font-normal text-muted-foreground">(optional)</span></label><input name="testEndpoint" value={formik.values.testEndpoint} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="https://your-site.com/api/pim/test" className={inputClass()} /><p className="mt-1 text-xs text-muted-foreground">Test Connection sends a small <code>pim.connection.test</code> JSON request here. If blank, it uses the Product Delivery URL.</p>{formik.touched.testEndpoint && formik.errors.testEndpoint && <p className="mt-1 text-xs text-red-500">{formik.errors.testEndpoint}</p>}</div>
<div className="rounded-lg border border-border bg-surface-muted p-4 text-sm"><p className="font-semibold">What happens after Save</p><ol className="mt-2 list-decimal space-y-1 pl-5"><li>The secret is encrypted and the Integration stays Pending.</li><li>Test Connection verifies the URL and credentials.</li><li>Your Channel mapping builds the JSON body.</li><li>Trigger Sync queues products; the worker sends them with retries and idempotency.</li><li>Jobs and Error Center show every product result.</li></ol></div>
{connectionMessage && <div className="rounded-lg border border-border p-3 text-sm">{connectionMessage}</div>}
</>
)}
</div>
@@ -844,59 +843,12 @@ export default function NewIntegration() {
<div>
<label className="block text-sm font-medium text-foreground mb-3">Sync Direction</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: "pim_to_channel", label: "PIM → Channel", sub: "Publish product data to the external system", icon: "↑" },
{ id: "channel_to_pim", label: "Channel → PIM", sub: "Import data from external system into PIM", icon: "↓" },
{ id: "bidirectional", label: "Bidirectional", sub: "Sync data in both directions", icon: "↕" },
].map((d) => {
const isSelected = formik.values.syncDirection === d.id;
return (
<button
key={d.id}
type="button"
onClick={() => formik.setFieldValue("syncDirection", d.id)}
className={`flex flex-col items-center text-center p-4 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
}`}
>
<span className={`text-xl font-bold ${isSelected ? "text-primary" : "text-muted-foreground"}`}>{d.icon}</span>
<div>
<div className={`text-sm font-semibold ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{d.label}</div>
<div className="text-[11px] text-muted-foreground mt-1">{d.sub}</div>
</div>
</button>
);
})}
</div>
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="text-sm font-semibold text-primary-dark">PIM External System</div><div className="mt-1 text-xs text-muted-foreground">This implemented flow publishes PIM products outward. Import and bidirectional sync will appear only after their conflict and ownership rules exist.</div></div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-3">Sync Frequency</label>
<div className="grid grid-cols-4 gap-3">
{[
{ id: "manual", label: "Manual", icon: Settings2 },
{ id: "hourly", label: "Hourly", icon: Clock },
{ id: "daily", label: "Daily", icon: Calendar },
{ id: "realtime", label: "Realtime", icon: Wifi },
].map((f) => {
const Icon = f.icon;
const isSelected = formik.values.syncFrequency === f.id;
return (
<button
key={f.id}
type="button"
onClick={() => formik.setFieldValue("syncFrequency", f.id)}
className={`flex flex-col items-center text-center py-4 px-2 rounded-xl border-2 transition-all gap-2 ${
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-border"
}`}
>
<Icon className={`w-5 h-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
<span className={`text-sm font-medium ${isSelected ? "text-primary-dark" : "text-foreground"}`}>{f.label}</span>
</button>
);
})}
</div>
<div className="rounded-xl border-2 border-primary bg-primary/5 p-4"><div className="flex items-center gap-2 text-sm font-semibold text-primary-dark"><Settings2 className="h-4 w-4" />Manual trigger</div><div className="mt-1 text-xs text-muted-foreground">Use Trigger Sync from Channel Registry. Hourly, daily and realtime schedules are hidden until a scheduler is implemented.</div></div>
</div>
<div className="border border-border rounded-xl p-4 space-y-4">
@@ -943,37 +895,9 @@ export default function NewIntegration() {
{activeStep === 'advanced' && (
<div className="space-y-4">
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-background transition-colors"
>
<div>
<p className="font-medium text-foreground">Localisation & Defaults</p>
<p className="text-xs text-muted-foreground mt-0.5">Configure default locale, currency, and timezone for this integration</p>
</div>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{[
{ label: "Field Mapping", desc: "Map PIM attribute fields to external system field schemas" },
{ label: "Category Mapping", desc: "Map PIM category taxonomy to external system categories" },
{ label: "Transformation Rules", desc: "Define data transformation and enrichment rules during sync" },
].map((item) => (
<div key={item.label} className="bg-surface rounded-xl border border-border shadow-sm p-5 flex items-center gap-4">
<div className="w-9 h-9 bg-surface-muted rounded-lg flex items-center justify-center">
<Settings2 className="w-4 h-4 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-foreground">{item.label}</p>
<span className="px-2 py-0.5 text-[10px] font-bold uppercase bg-amber-50 text-amber-600 border border-amber-200 rounded">Coming Soon</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">{item.desc}</p>
</div>
</div>
))}
<div className="rounded-xl border border-green-200 bg-green-50 p-5"><p className="font-medium text-green-900">Field mapping and transformations are available now</p><p className="mt-1 text-xs text-green-800">Configure them on the selected Channel. Saved mappings are already used by CSV export and syndication payload generation.</p><button type="button" onClick={() => navigate('/channels')} className="mt-3 text-sm font-semibold text-green-900 underline">Open Channel Registry</button></div>
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Localisation defaults later</p><p className="mt-1 text-xs text-muted-foreground">Not required to verify Shopify credentials or publish a first basic product. Add locale, currency and Shopify Markets behavior afterward.</p></div>
<div className="rounded-xl border border-border bg-surface p-5"><p className="font-medium">Category mapping later</p><p className="mt-1 text-xs text-muted-foreground">Not required for the first product. Shopify collections and taxonomy mapping belong in the next publishing phase.</p></div>
</div>
)}
@@ -992,7 +916,6 @@ export default function NewIntegration() {
{/* Right: Integration Summary */}
<IntegrationSummary
disabled={isView}
name={formik.values.name}
channel={formik.values.channel}
channelName={selectedChanName}
@@ -1,31 +1,91 @@
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
import apiClient from '../../../api/axiosInstance';
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest, TestConnectionResult, SyncJob, SyncItem } from '../types/integrations.types';
interface ApiResponse<T> {
type ApiResponse<T> = {
success: boolean;
data: T;
message?: string;
}
data: T;
};
export const integrationsService = {
getAll: async (): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations');
return res.data || [];
export const integrationService = {
getAll: async (params?: Record<string, any>): Promise<Integration[]> => {
const res = await apiClient.get<ApiResponse<Integration[]>>('/api/v1/integrations', { params });
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;
getById: async (id: string): Promise<Integration> => {
const res = await apiClient.get<ApiResponse<Integration>>();
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;
create: async (data: IntegrationCreateRequest): Promise<Integration> => {
const res = await apiClient.post<ApiResponse<Integration>>('/api/v1/integrations', 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;
update: async (id: string, data: IntegrationUpdateRequest): Promise<Integration> => {
const res = await apiClient.put<ApiResponse<Integration>>(, 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;
const res = await apiClient.delete<ApiResponse<any>>();
return res.success || true;
},
getCredentials: async (id: string): Promise<Record<string, string>> => {
const res = await apiClient.get<ApiResponse<Record<string, string>>>();
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>>(, {
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>>();
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>>(, options || {});
const raw: any = res.data;
return raw?.data || raw;
},
getSyncJobs: async (id: string): Promise<SyncJob[]> => {
const res = await apiClient.get<ApiResponse<SyncJob[]>>();
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[]>>();
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[]>>();
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>>();
const raw: any = res.data;
return raw?.data || raw;
}
};
@@ -2,17 +2,26 @@ 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;
integrationType?: string;
environment?: string;
status: string;
health_status?: string;
healthStatus?: string;
sync_mode?: string;
syncMode?: string;
sync_frequency?: string;
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;
@@ -42,22 +51,94 @@ export interface Integration {
// Custom API specific fields
customApiUrl?: string;
customApiHeaderKey?: string;
endpoint?: string;
testEndpoint?: string;
method?: string;
customApiHeaderName?: string;
customApiHeaderValue?: string;
clearSecretFields?: string[];
// Sync settings
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
syncFrequency: string; // manual, hourly, daily, realtime
autoRetry: boolean;
retryAttempts: number;
syncDirection?: string;
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;
}
@@ -1,22 +1,17 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react";
import { Building2, Users, Package, Image, Activity } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { tenantService } from "../../tenants/services/tenant.service";
import { useTenant } from "../../tenants/hooks/useTenant";
import { notify } from "../../../services/toast";
export default function PlatformOverview() {
const navigate = useNavigate();
const { impersonateTenant, stopImpersonation } = useTenant();
const [metrics, setMetrics] = useState<any>(null);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
@@ -38,61 +33,21 @@ export default function PlatformOverview() {
loadData();
}, []);
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
} catch (err) {
// Handled in hook
}
};
const handleStopImpersonate = () => {
stopImpersonation();
setImpersonatingTenantId(null);
};
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
items={[{ label: "SaaS Dashboard" }]}
actions={
<Button
variant="primary"
icon={<Building2 className="w-4 h-4" />}
onClick={() => navigate("/platform/tenants")}
>
Provision New Tenant
Manage Tenants
</Button>
}
/>
{/* Support Impersonation Banner */}
{impersonatingTenantId && (
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500 text-white font-bold">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h4 className="font-semibold text-foreground text-sm">Support Impersonation Mode Active</h4>
<p className="text-xs text-muted-foreground">
Currently troubleshooting Tenant ID: <span className="font-mono font-bold text-amber-500">{impersonatingTenantId}</span>. Requests are safely scoped to this tenant context.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
icon={<StopCircle className="w-4 h-4 text-danger" />}
onClick={handleStopImpersonate}
>
End Support Mode
</Button>
</div>
)}
{/* Metrics Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
@@ -140,12 +95,12 @@ export default function PlatformOverview() {
</div>
</div>
{/* Tenants Table Preview */}
{/* Recent tenant health preview. Full lifecycle management lives in Tenants. */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Tenant Provisioning Registry</h3>
<h3 className="font-semibold text-foreground text-sm">Recent Tenant Health</h3>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate("/platform/tenants")}>
Manage All Tenants
@@ -162,20 +117,19 @@ export default function PlatformOverview() {
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Support Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
</tr>
) : (
tenants.map((t) => (
tenants.slice(0, 5).map((t) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
@@ -187,18 +141,6 @@ export default function PlatformOverview() {
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right">
{String(t.id) === String(impersonatingTenantId) ? (
<span className="text-xs text-amber-500 font-semibold">Active Session</span>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
<Play className="w-3.5 h-3.5" /> Support Assist
</button>
)}
</td>
</tr>
))
)}
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Plus, Building2, Search, CheckCircle, XCircle, Copy, Check } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
@@ -13,7 +12,6 @@ const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`;
export default function PlatformTenantsPage() {
const navigate = useNavigate();
const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant();
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
@@ -83,10 +81,17 @@ export default function PlatformTenantsPage() {
};
const handleStartImpersonate = async (tenantId: string) => {
const tenant = (tenants || []).find((item: any) => String(item.id) === String(tenantId));
const confirmed = window.confirm(
`Enter Support Mode for ${tenant?.tenant_name || `Tenant #${tenantId}`}?\n\n` +
'You will operate inside this tenant workspace. All support actions are tenant-scoped and audited.'
);
if (!confirmed) return;
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
navigate("/products");
window.location.assign("/products");
} catch (err) {
// Handled in hook
}
@@ -169,9 +174,8 @@ export default function PlatformTenantsPage() {
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(String(t.id), t.status)}
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${
t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
}`}
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
}`}
title={t.status ? "Suspend Tenant" : "Activate Tenant"}
>
{t.status ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
@@ -182,14 +186,14 @@ export default function PlatformTenantsPage() {
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
End Support
Exit Support
</button>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
Support Assist
Support Access
</button>
)}
</td>
@@ -265,7 +269,7 @@ export default function PlatformTenantsPage() {
<div className="pt-2 border-t border-border">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">Initial Tenant Admin Credentials</h4>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Name</label>
@@ -34,6 +34,9 @@ interface DynamicAttributesSectionProps {
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: any) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
@@ -46,6 +49,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
if (!hasAttributeSet) {
@@ -95,6 +101,9 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
onAttributeChange={onAttributeChange}
onAttributeBlur={onAttributeBlur}
onAddAttributeClick={onAddAttributeClick}
onRemoveAttribute={onRemoveAttribute}
onRemoveGroup={onRemoveGroup}
customAttributeIds={customAttributeIds}
readOnly={readOnly}
/>
))}
@@ -11,6 +11,7 @@ import { toast } from 'react-toastify';
import { Loader } from '../../../components/customs/Loader';
import { getAssetUrl, isImageFile } from '../../../lib/utils';
import { Select } from '../../../components/customs/Select';
import { variantService } from '../services/variant.service';
interface ProductAssetsTabProps {
productId?: string;
@@ -26,6 +27,62 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
refreshProductData
}) => {
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
const [assignedVariantAssets, setAssignedVariantAssets] = useState<any[]>([]);
const [variants, setVariants] = useState<any[]>([]);
const [selectedVariantId, setSelectedVariantId] = useState<string>('global');
const [isBulkMode, setIsBulkMode] = useState(false);
const [selectedVariantIds, setSelectedVariantIds] = useState<string[]>([]);
// Strict product isolation & configured variants filtering
const configuredVariants = useMemo(() => {
const productSpecific = variants.filter((v: any) => {
const pId = v.parentProductId || v.product_id || v.productId;
return !pId || pId === productId;
});
if (productSpecific.length <= 1) return productSpecific;
return productSpecific.filter((v: any) => {
const hasAttrObj = v.attributes && Object.keys(v.attributes).length > 0;
const hasAttrArr = Array.isArray(v.values) && v.values.length > 0;
return hasAttrObj || hasAttrArr;
});
}, [variants, productId]);
const variantFilterOptions = useMemo(() => {
const map = new Map<string, { name: string; values: Set<string> }>();
configuredVariants.forEach(v => {
if (v.attributes && typeof v.attributes === 'object') {
Object.entries(v.attributes).forEach(([code, val]) => {
if (val !== undefined && val !== null && val !== '') {
if (!map.has(code)) {
map.set(code, { name: code.toUpperCase().replace(/_/g, ' '), values: new Set() });
}
map.get(code)?.values.add(String(val));
}
});
}
if (Array.isArray(v.values)) {
v.values.forEach((val: any) => {
if (val.axis) {
const code = val.axis.code;
if (!map.has(code)) {
map.set(code, { name: val.axis.name || code, values: new Set() });
}
map.get(code)?.values.add(String(val.value_text));
}
});
}
});
const result: { code: string; name: string; values: string[] }[] = [];
map.forEach((data, code) => {
result.push({
code,
name: data.name,
values: Array.from(data.values)
});
});
return result;
}, [configuredVariants]);
const [loading, setLoading] = useState(false);
// Library picker states
@@ -66,7 +123,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
if (!matchedFamily) {
return [];
return allAssetTypes;
}
const allowedTypeIds =
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
@@ -75,24 +132,31 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
.map((at: any) => at.id || at.assetTypeId)
.filter(Boolean);
return allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
const filtered = allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
return filtered.length > 0 ? filtered : allAssetTypes;
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
// Reset selected asset type if it is no longer allowed by the newly selected family
// Auto-select initial asset type if none selected or if selected is no longer allowed
useEffect(() => {
if (selectedAssetTypeId && selectedAssetFamilyId) {
const isStillAllowed = filteredAssetTypes.some(at => at.id === selectedAssetTypeId);
if (!isStillAllowed) {
setSelectedAssetTypeId('');
if (filteredAssetTypes.length > 0) {
if (!selectedAssetTypeId || !filteredAssetTypes.some(at => at.id === selectedAssetTypeId)) {
setSelectedAssetTypeId(filteredAssetTypes[0].id);
}
}
}, [selectedAssetFamilyId, filteredAssetTypes, selectedAssetTypeId]);
}, [filteredAssetTypes, selectedAssetTypeId]);
// Resolve selected asset type details
const selectedAssetType = useMemo(() => {
return allAssetTypes.find(at => at.id === selectedAssetTypeId);
return allAssetTypes.find(at => at.id === selectedAssetTypeId) || allAssetTypes[0] || null;
}, [allAssetTypes, selectedAssetTypeId]);
// Reset selected variant target if the asset type does not support variants
useEffect(() => {
if (selectedAssetType && !(selectedAssetType.is_variant_eligible || selectedAssetType.isVariantEligible)) {
setSelectedVariantId('global');
}
}, [selectedAssetType]);
// Resolve required asset type ids from family requirements
const requiredAssetTypeIds = useMemo(() => {
const ids: string[] = [];
@@ -112,6 +176,53 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
return [...new Set(ids)];
}, [family, allAssetFamilies]);
const combinedAssets = useMemo(() => {
const list: any[] = [];
const validVariantIds = new Set(configuredVariants.map(v => v.id));
assignedAssets.forEach(a => {
const aProdId = a.product_id || (a as any).productId;
if (!aProdId || aProdId === productId) {
list.push({
...a,
isVariant: false,
variantId: undefined,
scopeLabel: 'Global'
});
}
});
assignedVariantAssets.forEach(va => {
const vId = va.variantId || va.variant_id || va.variant?.id;
if (vId && (validVariantIds.size === 0 || validVariantIds.has(vId))) {
const matchedVariant = configuredVariants.find(v => v.id === vId);
const vName = va.variantName || va.variant?.name || matchedVariant?.name || 'Variant';
const vSku = va.variantSku || va.variant?.sku || matchedVariant?.sku || '';
list.push({
...va,
variantId: vId,
variant_id: vId,
isVariant: true,
scopeLabel: `Variant: ${vName.split(' - ')[1] || vName} (${vSku || 'No SKU'})`
});
}
});
return list;
}, [assignedAssets, assignedVariantAssets, configuredVariants, productId]);
const [scopeFilter, setScopeFilter] = useState<string>('all');
const displayedAssets = useMemo(() => {
if (scopeFilter === 'global') {
return combinedAssets.filter(a => !a.isVariant);
}
if (scopeFilter !== 'all') {
return combinedAssets.filter(a => a.isVariant && a.variantId === scopeFilter);
}
return combinedAssets;
}, [combinedAssets, scopeFilter]);
const isAssetTypeRequiredByFamily = (code: string) => {
const matchingType = allAssetTypes.find(at => at.code === code);
if (!matchingType) return false;
@@ -120,21 +231,42 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Load product assets
const loadProductAssets = async () => {
if (!productId) return;
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setAssignedAssets([]);
setVariants([]);
setAssignedVariantAssets([]);
return;
}
setLoading(true);
try {
const data = await assetsService.getProductAssets(productId);
// Sort by display order
const [data, productVariants, allVariantAssets] = await Promise.all([
assetsService.getProductAssets(productId),
variantService.getByProduct(productId),
assetsService.getAllVariantAssets(productId)
]);
const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
setAssignedAssets(sorted);
const rawVars = Array.isArray(productVariants) ? productVariants : (productVariants as any)?.data || [];
const productIsolatedVars = (Array.isArray(rawVars) ? rawVars : []).filter((v: any) => {
const pId = v.parentProductId || v.product_id || v.productId;
return !pId || pId === productId;
});
setVariants(productIsolatedVars);
const rawVarAssets = Array.isArray(allVariantAssets) ? allVariantAssets : (allVariantAssets as any)?.data || [];
setAssignedVariantAssets(Array.isArray(rawVarAssets) ? rawVarAssets : []);
} catch (err: any) {
toast.error(err?.message || 'Failed to load product assets');
toast.error(err?.message || 'Failed to load assets');
} finally {
setLoading(false);
}
};
useEffect(() => {
setSelectedVariantId('global');
setSelectedVariantIds([]);
setIsBulkMode(false);
setScopeFilter('all');
loadProductAssets();
}, [productId]);
@@ -234,13 +366,29 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
}
// Map this asset to current product
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
// Map this asset to current product or variant(s)
if (isBulkMode && selectedVariantIds.length > 0) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: newAsset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else if (selectedVariantId !== 'global') {
await assetsService.assignVariantAsset(selectedVariantId, {
asset_id: newAsset.id,
role,
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary) && successCount === 0,
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length + successCount
});
} else {
await assetsService.assignProductAsset(productId, {
asset_id: newAsset.id,
role,
is_primary: assignedAssets.length === 0 && successCount === 0,
display_order: assignedAssets.length + successCount
});
}
successCount++;
} catch (err: any) {
@@ -280,6 +428,10 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleMultipleFilesUpload(e.dataTransfer.files);
}
@@ -287,11 +439,20 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Assign asset from the modal picker
const handleAssignFromLibrary = async (asset: Asset) => {
// Check if already assigned
const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id);
if (alreadyAssigned) {
toast.warn('Asset is already assigned to this product');
return;
if (!isBulkMode) {
if (selectedVariantId !== 'global') {
const alreadyAssignedVariant = assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.asset_id === asset.id);
if (alreadyAssignedVariant) {
toast.warn('Asset is already assigned to this variant');
return;
}
} else {
const alreadyAssigned = assignedAssets.some(m => m.asset_id === asset.id);
if (alreadyAssigned) {
toast.warn('Asset is already assigned to this product');
return;
}
}
}
try {
@@ -313,12 +474,28 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
}
}
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
if (isBulkMode && selectedVariantIds.length > 0) {
await assetsService.bulkAssignVariantAsset(productId, {
asset_id: asset.id,
role,
variant_ids: selectedVariantIds,
is_primary: false
});
} else if (selectedVariantId !== 'global') {
await assetsService.assignVariantAsset(selectedVariantId, {
asset_id: asset.id,
role,
is_primary: !assignedVariantAssets.some(m => m.variantId === selectedVariantId && m.is_primary),
display_order: assignedVariantAssets.filter(m => m.variantId === selectedVariantId).length
});
} else {
await assetsService.assignProductAsset(productId, {
asset_id: asset.id,
role,
is_primary: assignedAssets.length === 0,
display_order: assignedAssets.length
});
}
toast.success('Asset assigned from library');
loadProductAssets();
@@ -329,10 +506,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
};
// Unassign asset mapping
const handleUnassign = async (assetId: string) => {
if (!window.confirm('Are you sure you want to unassign this asset from the product?')) return;
const handleUnassign = async (assetId: string, variantId?: string) => {
if (!window.confirm('Are you sure you want to unassign this asset?')) return;
try {
await assetsService.unassignProductAsset(productId, assetId);
if (variantId) {
await assetsService.unassignVariantAsset(variantId, assetId);
} else {
await assetsService.unassignProductAsset(productId, assetId);
}
toast.success('Asset unassigned');
loadProductAssets();
refreshProductData?.();
@@ -344,9 +525,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
// Set selected asset as the primary display image
const handleSetPrimary = async (assetId: string) => {
const handleSetPrimary = async (assetId: string, variantId?: string) => {
try {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
if (variantId) {
await assetsService.updateVariantAsset(variantId, assetId, { is_primary: true });
} else {
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
}
toast.success('Primary image updated');
loadProductAssets();
refreshProductData?.();
@@ -380,9 +565,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const filteredLibrary = libraryAssets.filter(asset =>
asset.name.toLowerCase().includes(pickerSearch.toLowerCase())
);
const filteredLibrary = libraryAssets.filter(asset => {
const matchesSearch = asset.name.toLowerCase().includes(pickerSearch.toLowerCase());
if (!matchesSearch) return false;
if (selectedAssetTypeId && asset.asset_type_id) {
if (asset.asset_type_id !== selectedAssetTypeId) return false;
}
return true;
});
return (
<div className="space-y-6">
@@ -461,8 +651,148 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</Select>
</div>
</div>
{/* Variant Target Selector */}
{configuredVariants.length > 0 && (
<div className="flex-1 min-w-[240px]">
<label className="text-xs font-bold text-foreground block mb-2 flex items-center justify-between">
<span>Variant Target Scope</span>
<button
type="button"
onClick={() => {
setIsBulkMode(!isBulkMode);
setSelectedVariantIds([]);
setSelectedVariantId('global');
}}
className="text-[10px] text-primary hover:underline font-bold cursor-pointer"
>
{isBulkMode ? "Switch to Single Target" : "Switch to Bulk Assignment"}
</button>
</label>
<div className="relative">
{isBulkMode ? (
<div className="bg-primary/5 border border-primary/20 rounded-lg p-2 text-xs font-semibold text-primary-dark">
Bulk Mode Active ({selectedVariantIds.length} selected)
</div>
) : (
<Select
id="variant-target-select"
value={selectedVariantId}
onChange={(e) => setSelectedVariantId(e.target.value)}
className="w-full text-xs font-semibold border-primary/20 bg-primary/5/10 text-primary-dark"
>
<option value="global">Global (Product level)</option>
{configuredVariants.map((v) => (
<option key={v.id} value={v.id}>
Variant: {v.name.split(' - ')[1] || v.name} ({v.sku || 'No SKU'})
</option>
))}
</Select>
)}
</div>
</div>
)}
</div>
{/* Bulk Selection and Attribute Filters Panel */}
{isBulkMode && (
<div className="bg-background border border-border rounded-lg p-4 space-y-3 animate-fade-in">
<div className="flex justify-between items-center border-b border-border pb-2">
<span className="text-xs font-bold text-foreground">Bulk Variant Target Selection</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedVariantIds(configuredVariants.map(v => v.id))}
className="px-2 py-1 bg-surface border border-border hover:bg-background rounded text-[10px] font-bold text-muted-foreground cursor-pointer"
>
Select All
</button>
<button
type="button"
onClick={() => setSelectedVariantIds([])}
className="px-2 py-1 bg-surface border border-border hover:bg-background rounded text-[10px] font-bold text-muted-foreground cursor-pointer"
>
Clear All
</button>
</div>
</div>
{/* Dynamic Attribute Filter Pills */}
{variantFilterOptions.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider block">Auto-Select by Specification:</span>
<div className="flex flex-col gap-2">
{variantFilterOptions.map(option => (
<div key={option.code} className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold text-foreground min-w-[60px]">{option.name}:</span>
<div className="flex gap-1.5 flex-wrap">
{option.values.map(val => {
const matchingIds = configuredVariants
.filter(v => {
if (v.attributes && String(v.attributes[option.code]) === val) return true;
if (Array.isArray(v.values) && v.values.some((av: any) => av.axis?.code === option.code && String(av.value_text) === val)) return true;
return false;
})
.map(v => v.id);
const isAllSelected = matchingIds.every(id => selectedVariantIds.includes(id));
return (
<button
type="button"
key={val}
onClick={() => {
if (isAllSelected) {
setSelectedVariantIds(prev => prev.filter(id => !matchingIds.includes(id)));
} else {
setSelectedVariantIds(prev => Array.from(new Set([...prev, ...matchingIds])));
}
}}
className={`px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors cursor-pointer ${
isAllSelected
? 'bg-primary text-white border-primary'
: 'bg-surface hover:bg-background text-foreground border-border'
}`}
>
{val}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{/* Variants Checkbox Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 pt-2 border-t border-border max-h-[200px] overflow-y-auto">
{configuredVariants.map(v => {
const isChecked = selectedVariantIds.includes(v.id);
return (
<label key={v.id} className="flex items-center gap-2 p-1.5 hover:bg-surface-muted rounded-md cursor-pointer transition-colors text-[11px] font-medium text-foreground">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
if (e.target.checked) {
setSelectedVariantIds(prev => [...prev, v.id]);
} else {
setSelectedVariantIds(prev => prev.filter(id => id !== v.id));
}
}}
className="rounded border-border text-primary focus:ring-primary w-3.5 h-3.5 cursor-pointer"
/>
<span className="truncate" title={v.name}>
{v.name.split(' - ')[1] || v.name} ({v.sku || 'No SKU'})
</span>
</label>
);
})}
</div>
</div>
)}
{selectedAssetType && (() => {
const allowedFileTypes =
selectedAssetType.validation?.allowedFileTypes ??
@@ -509,10 +839,15 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
fileInputRef.current?.click();
}}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
}`}
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
} ${isBulkMode && selectedVariantIds.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input
type="file"
@@ -543,6 +878,14 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<p className="text-[10px] mt-1">Classification is required before mapping files to product registry</p>
</div>
</div>
) : isBulkMode && selectedVariantIds.length === 0 ? (
<div className="space-y-2 text-warning">
<Info className="w-8 h-8 mx-auto animate-pulse" />
<div>
<h4 className="font-semibold text-xs">Select Variants Below</h4>
<p className="text-[10px] mt-1">Check at least one variant before uploading files</p>
</div>
</div>
) : (
<div className="space-y-3">
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
@@ -569,18 +912,29 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
toast.error('Please select an Asset Type first.');
return;
}
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
fileInputRef.current?.click();
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedAssetType}
disabled={!selectedAssetType || (isBulkMode && selectedVariantIds.length === 0)}
>
<Plus className="w-3.5 h-3.5" />
+ Add Asset
</button>
<button
type="button"
onClick={() => setShowPicker(true)}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
onClick={() => {
if (isBulkMode && selectedVariantIds.length === 0) {
toast.error('Please select at least one variant target for bulk assignment.');
return;
}
setShowPicker(true);
}}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isBulkMode && selectedVariantIds.length === 0}
>
<Search className="w-3.5 h-3.5" />
Browse Library
@@ -589,14 +943,70 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
</div>
)}
{/* Grid of assigned assets */}
{loading && assignedAssets.length === 0 ? (
{loading && combinedAssets.length === 0 ? (
<div className="flex justify-center py-10">
<Loader size="md" message="Loading assigned files..." />
</div>
) : assignedAssets.length > 0 ? (
) : combinedAssets.length > 0 ? (
<div className="bg-surface rounded-xl border border-border shadow-xs overflow-hidden">
{/* Scope Filter Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 bg-background/50 border-b border-border text-xs">
<div className="flex items-center gap-2 overflow-x-auto">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider shrink-0 mr-1">Filter Scope:</span>
<button
type="button"
onClick={() => setScopeFilter('all')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'all'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
All Assets ({combinedAssets.length})
</button>
<button
type="button"
onClick={() => setScopeFilter('global')}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
scopeFilter === 'global'
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
Global ({assignedAssets.length})
</button>
{configuredVariants.map(v => {
const count = assignedVariantAssets.filter(va => (va.variantId || va.variant_id) === v.id).length;
if (count === 0) return null;
const isSelected = scopeFilter === v.id;
return (
<button
key={v.id}
type="button"
onClick={() => setScopeFilter(v.id)}
className={`px-2.5 py-1 rounded-full font-semibold transition-colors shrink-0 ${
isSelected
? 'bg-primary text-white'
: 'bg-surface hover:bg-background border border-border text-muted-foreground'
}`}
>
Variant: {v.name.split(' - ')[1] || v.name} ({count})
</button>
);
})}
</div>
{scopeFilter !== 'all' && (
<button
type="button"
onClick={() => setScopeFilter('all')}
className="text-[10px] text-muted-foreground hover:text-foreground font-semibold shrink-0"
>
Reset Filter
</button>
)}
</div>
<table className="w-full border-collapse text-left text-xs text-foreground">
<thead>
<tr className="bg-background border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
@@ -608,7 +1018,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-border">
{assignedAssets.map((mapping, idx) => {
{displayedAssets.map((mapping, idx) => {
const asset = mapping.asset;
if (!asset) return null;
@@ -618,28 +1028,32 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
const is3DModel = asset.mime_type?.startsWith('model/') || ['glb', 'gltf', 'usdz'].includes(asset.extension || '');
return (
<tr key={mapping.id} className="hover:bg-background/50 transition-colors">
<tr key={mapping.id || `${mapping.asset_id}-${mapping.variantId || 'global'}`} className="hover:bg-background/50 transition-colors">
{/* Display Order sorting */}
{!readOnly && (
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMoveOrder(idx, 'up')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={idx === assignedAssets.length - 1}
onClick={() => handleMoveOrder(idx, 'down')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{!mapping.isVariant ? (
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMoveOrder(idx, 'up')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={idx === assignedAssets.length - 1}
onClick={() => handleMoveOrder(idx, 'down')}
className="p-1 hover:bg-surface-muted rounded text-muted-foreground disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
) : (
<span className="text-[10px] text-muted-foreground font-mono"></span>
)}
</td>
)}
@@ -662,11 +1076,18 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
{/* Meta info */}
<td className="px-4 py-3 font-medium">
<div className="text-foreground font-semibold flex items-center gap-2">
{asset.name}
<div className="text-foreground font-semibold flex items-center gap-2 flex-wrap">
<span>{asset.name}</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-primary/10 text-primary border border-primary/20 uppercase">
{mapping.role ? mapping.role.replace('_', ' ') : 'HERO IMAGE'}
</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold border uppercase ${
mapping.isVariant
? 'bg-purple-50 text-purple-700 border-purple-100'
: 'bg-slate-50 text-slate-700 border-slate-100'
}`}>
{mapping.scopeLabel}
</span>
</div>
<div className="text-[10px] text-muted-foreground font-mono mt-1 flex items-center gap-2 flex-wrap">
<span>Size: {asset.file_size ? `${(asset.file_size / 1024).toFixed(1)} KB` : '—'}</span>
@@ -678,8 +1099,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
</td>
{/* Primary Badge toggle button */}
<td className="px-4 py-3 text-center">
{mapping.is_primary ? (
@@ -692,7 +1111,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
) : (
<button
type="button"
onClick={() => handleSetPrimary(asset.id)}
onClick={() => handleSetPrimary(asset.id, mapping.variantId)}
className="text-[11px] text-muted-foreground hover:text-primary font-semibold"
>
Make Primary
@@ -705,7 +1124,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleUnassign(asset.id)}
onClick={() => handleUnassign(asset.id, mapping.variantId)}
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-600 rounded-lg transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -762,7 +1181,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
</div>
) : filteredLibrary.length > 0 ? (
filteredLibrary.map(asset => {
const isAssigned = assignedAssets.some(a => a.asset_id === asset.id);
const isAssigned = assignedAssets.some(a => a.asset_id === asset.id) || assignedVariantAssets.some(va => va.asset_id === asset.id);
const isImage = isImageFile(asset.mime_type, asset.file_url);
const isVideo = asset.mime_type?.startsWith('video/');
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Plus } from 'lucide-react';
import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react';
import { DynamicAttributeRenderer } from './DynamicAttributeRenderer';
interface AttributeOption {
@@ -35,6 +35,9 @@ interface ProductAttributeGroupProps {
onAttributeChange: (code: string, value: any) => void;
onAttributeBlur?: (code: string) => void;
onAddAttributeClick?: (group: AttributeGroup) => void;
onRemoveAttribute?: (id: string) => void;
onRemoveGroup?: (id: string) => void;
customAttributeIds?: Set<string>;
readOnly?: boolean;
}
@@ -46,6 +49,9 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
onAttributeChange,
onAttributeBlur,
onAddAttributeClick,
onRemoveAttribute,
onRemoveGroup,
customAttributeIds,
readOnly,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
@@ -56,19 +62,34 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
<div className="bg-surface rounded-xl border border-border shadow-xs p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
<div className="flex items-center gap-2">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2 py-1 rounded hover:bg-red-50 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddAttributeClick(group);
}}
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors flex items-center justify-center shrink-0 h-[42px] w-[42px]"
title={`Add attribute to ${group.name}`}
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground italic">No Attributes available.</div>
</div>
@@ -83,6 +104,19 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
>
<h3 className="font-semibold text-foreground text-xs uppercase tracking-wider">{group.name}</h3>
<div className="flex items-center gap-3">
{!readOnly && onRemoveGroup && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveGroup(group.id);
}}
className="text-xs text-red-500 hover:text-red-650 font-semibold px-2.5 py-1 rounded hover:bg-red-50/70 transition-colors"
title={`Remove ${group.name} container`}
>
Remove Group
</button>
)}
{!readOnly && onAddAttributeClick && (
<button
type="button"
@@ -102,18 +136,32 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
{isExpanded && (
<div className="p-6 grid grid-cols-2 gap-6">
{attributes.map((attr) => (
<DynamicAttributeRenderer
key={attr.id}
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
/>
))}
{attributes.map((attr) => {
const isCustom = customAttributeIds?.has(attr.id);
return (
<div key={attr.id} className="relative border border-border/40 rounded-xl p-5 bg-background/15 group hover:border-border/80 transition-all">
{isCustom && !readOnly && onRemoveAttribute && (
<button
type="button"
onClick={() => onRemoveAttribute(attr.id)}
className="absolute top-2 right-2 p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
title="Remove custom attribute"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<DynamicAttributeRenderer
attribute={attr}
value={values[attr.code]}
onChange={(val) => onAttributeChange(attr.code, val)}
onBlur={() => onAttributeBlur?.(attr.code)}
error={errors[attr.code]}
touched={touched[attr.code]}
readOnly={readOnly}
/>
</div>
);
})}
</div>
)}
</div>
@@ -7,18 +7,29 @@ interface VariantAxesSelectorProps {
onGenerate: (selected: Record<string, string[]>, skuTemplate: string) => void;
generating: boolean;
parentSku: string;
initialSelectedValues?: Record<string, string[]>;
}
export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
axes,
onGenerate,
generating,
parentSku
parentSku,
initialSelectedValues
}) => {
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>({});
const [selectedValues, setSelectedValues] = useState<Record<string, string[]>>(initialSelectedValues || {});
const [skuTemplate, setSkuTemplate] = useState('{PARENT_SKU}-{COMBO}');
const [customInputs, setCustomInputs] = useState<Record<string, string>>({});
React.useEffect(() => {
if (initialSelectedValues) {
setSelectedValues(prev => ({
...prev,
...initialSelectedValues
}));
}
}, [initialSelectedValues]);
// Calculate combinations preview
const activeAxes = axes.filter(axis => (selectedValues[axis.code] || []).length > 0);
const totalCombinations = activeAxes.length > 0
@@ -112,15 +123,41 @@ export const VariantAxesSelector: React.FC<VariantAxesSelectorProps> = ({
return (
<div key={axis.id} className="space-y-2">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground">({axis.code})</span>
</label>
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground uppercase tracking-wider">
{axis.name} <span className="text-muted-foreground font-mono text-[10px]">({axis.code})</span>
{selected.length > 0 && (
<span className="ml-2 text-[10px] text-primary font-semibold bg-primary/10 px-2 py-0.5 rounded-full border border-primary/20">
{selected.length} selected
</span>
)}
</label>
{options.length > 0 && (
<div className="flex items-center gap-2 text-[11px]">
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: options.map(o => o.code) }))}
className="text-primary font-medium hover:underline"
>
Select All
</button>
<span className="text-muted-foreground/40"></span>
<button
type="button"
onClick={() => setSelectedValues(prev => ({ ...prev, [axis.code]: [] }))}
className="text-muted-foreground hover:text-foreground font-medium transition-colors"
>
Clear
</button>
</div>
)}
</div>
{options.length > 0 ? (
// Pre-defined options list checkbox layout
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{options.map(opt => {
const isChecked = selected.includes(opt.code);
const isChecked = selected.some(sel => sel.toLowerCase().trim() === opt.code.toLowerCase().trim());
return (
<label
key={opt.id}
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import type { VariantStatus } from '../../types/variant.types';
import { Settings, Check, Trash2, Archive, DollarSign, Package } from 'lucide-react';
import { Settings, Check, Trash2, Archive, DollarSign } from 'lucide-react';
interface VariantBulkActionsProps {
selectedCount: number;
onApplyUpdates: (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => void;
onApplyUpdates: (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => void;
onDeleteSelected: () => void;
onArchiveSelected: () => void;
}
@@ -16,25 +16,22 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
onArchiveSelected
}) => {
const [isOpen, setIsOpen] = useState(false);
const [actionType, setActionType] = useState<'price' | 'stock' | 'status' | null>(null);
const [actionType, setActionType] = useState<'price' | 'status' | null>(null);
// States for bulk inputs
const [bulkPrice, setBulkPrice] = useState('');
const [bulkCostPrice, setBulkCostPrice] = useState('');
const [bulkStock, setBulkStock] = useState('');
const [bulkStatus, setBulkStatus] = useState<VariantStatus>('draft');
if (selectedCount === 0) return null;
const handleApply = (e: React.FormEvent) => {
e.preventDefault();
const updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus } = {};
const updates: { price?: number; costPrice?: number; status?: VariantStatus } = {};
if (actionType === 'price') {
if (bulkPrice !== '') updates.price = parseFloat(bulkPrice);
if (bulkCostPrice !== '') updates.costPrice = parseFloat(bulkCostPrice);
} else if (actionType === 'stock') {
if (bulkStock !== '') updates.stock = parseInt(bulkStock, 10);
} else if (actionType === 'status') {
updates.status = bulkStatus;
}
@@ -80,13 +77,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
>
<DollarSign className="w-4 h-4 text-muted-foreground" /> Update Price & Cost
</button>
<button
type="button"
onClick={() => setActionType('stock')}
className="flex items-center gap-2 w-full text-left px-3 py-2 hover:bg-background rounded-lg text-xs text-foreground font-medium transition-colors"
>
<Package className="w-4 h-4 text-muted-foreground" /> Update Inventory Stock
</button>
<button
type="button"
onClick={() => setActionType('status')}
@@ -124,19 +114,6 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
</div>
)}
{actionType === 'stock' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Stock Level</label>
<input
type="number"
placeholder="Enter inventory quantity"
value={bulkStock}
onChange={(e) => setBulkStock(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface"
/>
</div>
)}
{actionType === 'status' && (
<div>
<label className="block text-[10px] font-bold text-muted-foreground uppercase">Lifecycle Status</label>
@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import {
X, Save, Archive, Trash2, Image as ImageIcon,
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
ShoppingBag, Hash
} from 'lucide-react';
interface VariantDetailModalProps {
variant: Variant | null;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete?: (id: string) => void;
onArchive?: (id: string) => void;
readOnly?: boolean;
}
export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
variant,
onClose,
onUpdate,
onDelete,
onArchive,
readOnly = false
}) => {
const [sku, setSku] = useState('');
const [price, setPrice] = useState('');
const [costPrice, setCostPrice] = useState('');
const [stock, setStock] = useState('');
const [status, setStatus] = useState<VariantStatus>('draft');
const [activeImageIdx, setActiveImageIdx] = useState(0);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
if (variant) {
setSku(variant.sku || '');
setPrice(String(variant.price ?? ''));
setCostPrice(String(variant.costPrice ?? ''));
setStock(String(variant.stock ?? ''));
setStatus(variant.status || 'draft');
setActiveImageIdx(0);
setSaveState('idle');
}
}, [variant]);
if (!variant) return null;
const images = variant.images || [];
const primaryImage = images.find(i => i.isPrimary) || images[0];
const activeImage = images[activeImageIdx] || primaryImage;
const axisEntries = Object.entries(variant.attributes || {});
const handleSave = async () => {
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
const hasChanges =
sku !== variant.sku ||
pNum !== variant.price ||
cpNum !== variant.costPrice ||
sNum !== variant.stock ||
status !== variant.status;
if (!hasChanges) return;
setSaveState('saving');
try {
await onUpdate(variant.id, {
sku,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch {
setSaveState('error');
setTimeout(() => setSaveState('idle'), 3000);
}
};
const statusColor: Record<VariantStatus, string> = {
active: 'bg-emerald-100 text-emerald-700 border-emerald-200',
draft: 'bg-amber-100 text-amber-700 border-amber-200',
inactive: 'bg-slate-100 text-slate-600 border-slate-200',
archived: 'bg-red-50 text-red-600 border-red-200'
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Package className="w-4 h-4 text-primary" />
</div>
<div>
<h2 className="font-bold text-foreground text-sm leading-tight">
{variant.name || 'Variant Details'}
</h2>
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
{variant.status}
</span>
<button
type="button"
onClick={onClose}
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-5 gap-0 h-full">
{/* Left: Image Gallery */}
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
{/* Main image */}
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
{activeImage?.url || activeImage?.thumbnailUrl ? (
<img
src={activeImage.thumbnailUrl || activeImage.url!}
alt={activeImage.name || variant.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ImageIcon className="w-10 h-10 opacity-30" />
<span className="text-[11px] font-medium">No image</span>
</div>
)}
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setActiveImageIdx(idx)}
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
>
{img.url || img.thumbnailUrl ? (
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
<ImageIcon className="w-3 h-3 text-muted-foreground" />
</div>
)}
</button>
))}
</div>
)}
{/* Axis pills */}
{axisEntries.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
<div className="flex flex-wrap gap-1.5">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5" />
<span className="text-muted-foreground capitalize">{key}:</span>
<span>{val}</span>
</span>
))}
</div>
</div>
)}
{images.length > 0 && (
<p className="text-[10px] text-muted-foreground text-center">
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
</p>
)}
</div>
{/* Right: Edit Fields */}
<div className="col-span-3 p-6 space-y-5">
{/* SKU */}
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<Hash className="inline w-3 h-3 mr-1" />SKU Code
</label>
{readOnly ? (
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
) : (
<input
type="text"
value={sku}
onChange={e => setSku(e.target.value)}
placeholder="e.g. PROD-RED-M"
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
{/* Price & Cost */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={e => setPrice(e.target.value)}
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
</div>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
) : (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm font-semibold">$</span>
<input
type="number"
step="0.01"
min="0"
value={costPrice}
onChange={e => setCostPrice(e.target.value)}
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
</div>
)}
</div>
</div>
{/* Stock & Status */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
</label>
{readOnly ? (
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
) : (
<input
type="number"
min="0"
value={stock}
onChange={e => setStock(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
/>
)}
</div>
<div>
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
{readOnly ? (
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
) : (
<select
value={status}
onChange={e => setStatus(e.target.value as VariantStatus)}
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground font-medium"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
)}
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
].map(({ label, value }) => (
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
<div className="text-lg font-bold text-foreground">{value}</div>
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
</div>
))}
</div>
{/* Last updated */}
{variant.lastUpdated && (
<p className="text-[10px] text-muted-foreground">
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
</p>
)}
</div>
</div>
</div>
{/* ── Footer ── */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 bg-background/50">
{/* Danger actions */}
<div className="flex items-center gap-2">
{!readOnly && onArchive && (
<button
type="button"
onClick={() => { onArchive(variant.id); onClose(); }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
>
<Archive className="w-3.5 h-3.5" />
Archive
</button>
)}
{!readOnly && onDelete && (
<button
type="button"
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
)}
</div>
{/* Primary actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
>
{readOnly ? 'Close' : 'Cancel'}
</button>
{!readOnly && (
<button
type="button"
onClick={handleSave}
disabled={saveState === 'saving'}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
>
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'error' && <AlertCircle className="w-3.5 h-3.5 text-white" />}
{saveState === 'idle' && <Save className="w-3.5 h-3.5" />}
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved!' : saveState === 'error' ? 'Error' : 'Save Changes'}
</button>
)}
</div>
</div>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import type { Variant, VariantStatus } from '../../types/variant.types';
import { Trash2, Archive, Loader, Check, CircleAlert } from 'lucide-react';
import { Trash2, Archive, Loader, Check, CircleAlert, Image as ImageIcon, Eye } from 'lucide-react';
interface VariantEditorRowProps {
variant: Variant;
@@ -10,6 +10,7 @@ interface VariantEditorRowProps {
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
onViewDetail?: () => void;
readOnly?: boolean;
}
@@ -21,6 +22,7 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
onUpdate,
onDelete,
onArchive,
onViewDetail,
readOnly
}) => {
const [sku, setSku] = useState(variant.sku);
@@ -77,14 +79,28 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
}
};
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
// ── Read-only row ──────────────────────────────────────────────────────────
if (readOnly) {
return (
<tr className="hover:bg-background/50 transition-colors border-b border-border">
<td className="px-3 py-2 text-center">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-8 h-8 object-cover rounded border border-border mx-auto" />
) : (
<div className="w-8 h-8 rounded border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-3.5 h-3.5" />
</div>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
{variant.name.split(' - ')[1] || variant.name}
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
@@ -99,10 +115,8 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
status === 'active' ? 'bg-success/10 text-success' :
@@ -128,11 +142,46 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
/>
</td>
{/* Image Thumbnail */}
<td className="px-3 py-2 text-center">
{onViewDetail ? (
<button type="button" onClick={onViewDetail} className="group relative block mx-auto focus:outline-none">
{thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border transition-all group-hover:border-primary" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center text-muted-foreground transition-all group-hover:border-primary">
<ImageIcon className="w-4 h-4" />
</div>
)}
</button>
) : (
thumbUrl ? (
<img src={thumbUrl} alt={variant.name} className="w-9 h-9 object-cover rounded-lg border border-border mx-auto" />
) : (
<div className="w-9 h-9 rounded-lg border border-border bg-surface-muted flex items-center justify-center mx-auto text-muted-foreground">
<ImageIcon className="w-4 h-4" />
</div>
)
)}
</td>
{/* SKU Input */}
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
{/* Variant Specification */}
<td className="px-4 py-3">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name}>
{variant.name.split(' - ')[1] || variant.name}
<span className="font-semibold text-foreground text-xs truncate max-w-[200px]" title={variant.name || ''}>
{(variant.name || '').split(' - ')[1] || variant.name || variant.sku}
</span>
<div className="flex flex-wrap gap-1 mt-1">
{axesKeys.map(key => {
@@ -148,18 +197,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</td>
{/* SKU Input */}
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
{/* Price Input */}
<td className="px-4 py-3">
<div className="relative">
@@ -192,18 +229,6 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
</div>
</td>
{/* Inventory Stock Input */}
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleFieldSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
{/* Status dropdown */}
<td className="px-4 py-3">
<select
@@ -229,6 +254,16 @@ export const VariantEditorRow: React.FC<VariantEditorRowProps> = ({
{/* Row Actions */}
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{onViewDetail && (
<button
type="button"
onClick={onViewDetail}
title="View & Edit Details"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => onArchive(variant.id)}
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantEditorRow } from './VariantEditorRow';
import { VariantDetailModal } from './VariantDetailModal';
interface VariantListViewProps {
variants: Variant[];
@@ -25,60 +26,80 @@ export const VariantListView: React.FC<VariantListViewProps> = ({
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-48">SKU Code</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-24 text-center">Stock</th>
<th className="px-4 py-3 w-32">Status</th>
{!readOnly && <th className="px-4 py-3 w-16 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3 w-24 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
readOnly={readOnly}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
<>
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[800px]">
<thead>
<tr className="bg-background/70 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3 text-center w-10">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
<th className="px-4 py-3 w-14">Image</th>
<th className="px-4 py-3 w-44">SKU Code</th>
<th className="px-4 py-3">Variant Specification</th>
<th className="px-4 py-3 w-28 text-right">Sale Price</th>
<th className="px-4 py-3 w-28 text-right">Cost Price</th>
<th className="px-4 py-3 w-28">Status</th>
{!readOnly && <th className="px-4 py-3 w-14 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3 w-28 text-right">Actions</th>}
</tr>
)}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<VariantEditorRow
key={variant.id}
variant={variant}
axesKeys={axesKeys}
isSelected={selectedIds.has(variant.id)}
onSelect={(checked) => onSelectChange(variant.id, checked)}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
onViewDetail={() => setModalVariant(variant)}
readOnly={readOnly}
/>
))}
{variants.length === 0 && (
<tr>
<td colSpan={readOnly ? 6 : 9} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={id => { onDelete(id); setModalVariant(null); }}
onArchive={id => { onArchive(id); setModalVariant(null); }}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import type { Variant } from '../../types/variant.types';
import { VariantDetailModal } from './VariantDetailModal';
import { Image as ImageIcon, Tag, Edit2, Trash2, Archive, CheckSquare, Square } from 'lucide-react';
interface VariantMatrixViewProps {
variants: Variant[];
@@ -16,8 +18,8 @@ interface VariantMatrixViewProps {
export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
variants,
axesKeys,
axesNames,
axesKeys: _axesKeys,
axesNames: _axesNames,
selectedIds,
onSelectChange,
onSelectAllChange,
@@ -26,274 +28,218 @@ export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
onArchive,
readOnly
}) => {
const [modalVariant, setModalVariant] = useState<Variant | null>(null);
const allSelected = variants.length > 0 && variants.every(v => selectedIds.has(v.id));
const someSelected = variants.length > 0 && variants.some(v => selectedIds.has(v.id)) && !allSelected;
return (
<div className="overflow-x-auto border border-border rounded-xl bg-surface shadow-xs">
<table className="w-full border-collapse text-left min-w-[900px]">
<thead>
<tr className="bg-primary/5/30 border-b border-border text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{!readOnly && (
<th className="px-4 py-3.5 text-center w-12">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => onSelectAllChange(e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</th>
)}
{/* Dynamic columns for each variant axis */}
{axesKeys.map(key => (
<th key={key} className="px-4 py-3.5 font-bold">
{axesNames[key] || key}
</th>
))}
<th className="px-4 py-3.5 w-48">SKU Code</th>
<th className="px-4 py-3.5 w-28 text-right">Sale Price</th>
<th className="px-4 py-3.5 w-28 text-right">Cost Price</th>
<th className="px-4 py-3.5 w-24 text-center">Stock</th>
<th className="px-4 py-3.5 w-32">Status</th>
{!readOnly && <th className="px-4 py-3.5 w-16 text-center">Save</th>}
{!readOnly && <th className="px-4 py-3.5 w-24 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{variants.map(variant => (
<tr
key={variant.id}
className={`hover:bg-background/50 transition-colors border-b border-border ${
selectedIds.has(variant.id) ? 'bg-primary/5/10' : ''
}`}
>
{/* Checkbox */}
{!readOnly && (
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={selectedIds.has(variant.id)}
onChange={(e) => onSelectChange(variant.id, e.target.checked)}
className="w-3.5 h-3.5 text-primary rounded border-border focus:ring-primary/20 cursor-pointer"
/>
</td>
)}
{/* Dynamic cells for each variant axis */}
{axesKeys.map(key => {
const val = variant.attributes[key];
return (
<td key={key} className="px-4 py-3">
<span className="inline-block bg-primary-light text-primary-dark font-semibold text-xs px-2 py-0.5 rounded-full font-mono">
{val || '—'}
</span>
</td>
);
})}
{/* Delegate fields to VariantEditorRow columns via inline styles or matching markup */}
{/* Note: Instead of nesting a complete table inside a tr, we just render the editor cells directly in the matrix tr. */}
{/* To make it extremely clean and reuse the state, we can let VariantEditorRow handle the cells but structure it to match. */}
{/* But since VariantEditorRow expects specific column layouts, we can render the matching tds right here in VariantMatrixView or adapt it. */}
{/* Adapting: Since a tr cannot easily contain another tr, let's render the editor cells inline here for the matrix view. */}
<InlineEditorCells
variant={variant}
onUpdate={onUpdate}
onDelete={onDelete}
onArchive={onArchive}
readOnly={readOnly}
/>
</tr>
))}
{variants.length === 0 && (
<tr>
<td colSpan={axesKeys.length + (readOnly ? 5 : 8)} className="px-6 py-10 text-center text-xs text-muted-foreground font-medium">
No variants found matching criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
// ── Inline Editor Cells Helper ────────────────────────────────────────────────
interface InlineCellsProps {
variant: Variant;
onUpdate: (id: string, updates: Partial<Variant>) => Promise<any>;
onDelete: (id: string) => void;
onArchive: (id: string) => void;
readOnly?: boolean;
}
const InlineEditorCells: React.FC<InlineCellsProps> = ({ variant, onUpdate, onDelete, onArchive, readOnly }) => {
const [sku, setSku] = useState(variant.sku);
const [price, setPrice] = useState(String(variant.price));
const [costPrice, setCostPrice] = useState(String(variant.costPrice));
const [stock, setStock] = useState(String(variant.stock));
const [status, setStatus] = useState(variant.status);
const [savingStatus, setSavingStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
setSku(variant.sku);
setPrice(String(variant.price));
setCostPrice(String(variant.costPrice));
setStock(String(variant.stock));
setStatus(variant.status);
}, [variant]);
const handleSave = async () => {
const pNum = parseFloat(price);
const cpNum = parseFloat(costPrice);
const sNum = parseInt(stock, 10);
const hasChanges =
sku !== variant.sku ||
pNum !== variant.price ||
cpNum !== variant.costPrice ||
sNum !== variant.stock ||
status !== variant.status;
if (!hasChanges) return;
setSavingStatus('saving');
try {
await onUpdate(variant.id, {
sku,
price: isNaN(pNum) ? 0 : pNum,
costPrice: isNaN(cpNum) ? 0 : cpNum,
stock: isNaN(sNum) ? 0 : sNum,
status
});
setSavingStatus('saved');
setTimeout(() => setSavingStatus('idle'), 1500);
} catch (err) {
setSavingStatus('error');
setTimeout(() => setSavingStatus('idle'), 3000);
const statusStyle = (s: string) => {
switch (s) {
case 'active': return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'draft': return 'bg-amber-100 text-amberald-700 border-amber-200';
case 'inactive': return 'bg-slate-100 text-slate-500 border-slate-200';
case 'archived': return 'bg-red-50 text-red-500 border-red-200';
default: return 'bg-surface-muted text-muted-foreground border-border';
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLElement).blur();
}
};
if (readOnly) {
if (variants.length === 0) {
return (
<>
<td className="px-4 py-3 font-mono text-xs text-foreground">{sku}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${price}</td>
<td className="px-4 py-3 text-right text-xs text-foreground">${costPrice}</td>
<td className="px-4 py-3 text-center text-xs text-foreground">{stock}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${
status === 'active' ? 'bg-success/10 text-success' :
status === 'draft' ? 'bg-warning/10 text-warning' : 'bg-surface-muted text-muted-foreground'
}`}>
{status}
</span>
</td>
</>
<div className="flex flex-col items-center justify-center py-20 bg-surface border border-dashed border-border rounded-xl text-muted-foreground gap-3">
<ImageIcon className="w-10 h-10 opacity-20" />
<p className="text-sm font-medium">No variants found</p>
</div>
);
}
return (
<>
<td className="px-4 py-3">
<input
type="text"
value={sku}
onChange={(e) => setSku(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary font-mono bg-surface"
/>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<div className="relative">
<span className="absolute left-1.5 top-1/2 -translate-y-1/2 text-muted-foreground text-[10px] font-semibold">$</span>
<input
type="number"
step="0.01"
value={costPrice}
onChange={(e) => setCostPrice(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded pl-4 pr-1 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-right"
/>
</div>
</td>
<td className="px-4 py-3">
<input
type="number"
value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="w-full text-xs border border-border rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-center"
/>
</td>
<td className="px-4 py-3">
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
onBlur={handleSave}
className="text-xs border border-border rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="archived">Archived</option>
</select>
</td>
<td className="px-4 py-3 text-center">
{savingStatus === 'saving' && <span className="inline-block w-3.5 h-3.5 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />}
{savingStatus === 'saved' && <span className="text-emerald-500 font-bold text-xs"></span>}
{savingStatus === 'error' && <span className="text-red-500 font-bold text-xs"></span>}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1.5">
{/* Select-all toolbar */}
{!readOnly && (
<div className="flex items-center gap-3 mb-3 px-1">
<button
type="button"
onClick={() => onArchive(variant.id)}
title="Archive variant"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
onClick={() => onSelectAllChange(!allSelected)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground font-medium transition-colors"
>
<span className="text-[11px]">Archive</span>
</button>
<button
type="button"
onClick={() => onDelete(variant.id)}
title="Delete variant"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<span className="text-[11px]">Delete</span>
{allSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : someSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Square className="w-3.5 h-3.5" />
)}
{allSelected ? 'Deselect All' : 'Select All'}
</button>
{selectedIds.size > 0 && (
<span className="text-xs font-semibold text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
{selectedIds.size} selected
</span>
)}
</div>
</td>
)}
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{variants.map(variant => {
const images = variant.images || [];
const primaryImg = images.find(i => i.isPrimary) || images[0];
const thumbUrl = primaryImg?.thumbnailUrl || primaryImg?.url;
const isSelected = selectedIds.has(variant.id);
const axisEntries = Object.entries(variant.attributes || {});
return (
<div
key={variant.id}
className={`group relative flex flex-col bg-surface border rounded-2xl overflow-hidden shadow-xs transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-primary/30'
}`}
>
{/* Selection checkbox overlay */}
{!readOnly && (
<div className="absolute top-2.5 left-2.5 z-10">
<button
type="button"
onClick={e => { e.stopPropagation(); onSelectChange(variant.id, !isSelected); }}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-primary border-primary'
: 'bg-white/80 border-border opacity-0 group-hover:opacity-100'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
)}
</button>
</div>
)}
{/* Status badge */}
<div className="absolute top-2.5 right-2.5 z-10">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-bold uppercase border ${statusStyle(variant.status)}`}>
{variant.status}
</span>
</div>
{/* Image area */}
<button
type="button"
onClick={() => setModalVariant(variant)}
className="relative w-full aspect-square bg-background overflow-hidden focus:outline-none"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={variant.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1.5 text-muted-foreground/40">
<ImageIcon className="w-8 h-8" />
<span className="text-[10px] font-medium">No image</span>
</div>
)}
{/* Image count badge */}
{images.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-[10px] font-semibold px-1.5 py-0.5 rounded-md backdrop-blur-sm">
+{images.length - 1}
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg">
<Edit2 className="w-4 h-4 text-foreground" />
</div>
</div>
</button>
{/* Card body */}
<div className="p-3 flex flex-col gap-2 flex-1">
{/* Axis pills */}
<div className="flex flex-wrap gap-1">
{axisEntries.map(([key, val]) => (
<span
key={key}
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-primary/8 border border-primary/15 rounded-full text-[10px] font-semibold text-primary"
>
<Tag className="w-2.5 h-2.5 opacity-60" />
{val}
</span>
))}
{axisEntries.length === 0 && (
<span className="text-[10px] text-muted-foreground">No axes</span>
)}
</div>
{/* SKU */}
<div className="font-mono text-[10px] text-muted-foreground truncate" title={variant.sku}>
{variant.sku || '—'}
</div>
{/* Price row */}
<div className="flex items-center justify-between mt-auto pt-1 border-t border-border">
<span className="text-sm font-bold text-foreground">
{variant.price > 0 ? `$${variant.price.toFixed(2)}` : <span className="text-muted-foreground text-xs">No price</span>}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!readOnly && (
<>
<button
type="button"
onClick={e => { e.stopPropagation(); setModalVariant(variant); }}
title="Edit"
className="p-1 hover:bg-primary/10 text-muted-foreground hover:text-primary rounded transition-colors"
>
<Edit2 className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); onArchive(variant.id); }}
title="Archive"
className="p-1 hover:bg-amber-50 text-muted-foreground hover:text-amber-600 rounded transition-colors"
>
<Archive className="w-3 h-3" />
</button>
<button
type="button"
onClick={e => { e.stopPropagation(); if (window.confirm('Delete variant?')) onDelete(variant.id); }}
title="Delete"
className="p-1 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Variant Detail Modal */}
{modalVariant && (
<VariantDetailModal
variant={modalVariant}
onClose={() => setModalVariant(null)}
onUpdate={async (id, updates) => {
const updated = await onUpdate(id, updates);
// Reflect updated data in the modal
if (updated) setModalVariant(prev => prev ? { ...prev, ...updates } : null);
return updated;
}}
onDelete={onDelete ? id => { onDelete(id); setModalVariant(null); } : undefined}
onArchive={onArchive ? id => { onArchive(id); setModalVariant(null); } : undefined}
readOnly={readOnly}
/>
)}
</>
);
};
@@ -5,8 +5,9 @@ import { VariantListView } from './VariantListView';
import { VariantMatrixView } from './VariantMatrixView';
import { VariantBulkActions } from './VariantBulkActions';
import type { VariantAxis, VariantStatus, Variant } from '../../types/variant.types';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers } from 'lucide-react';
import { Info, LayoutGrid, List, Plus, RefreshCw, Layers, X } from 'lucide-react';
import { Loader } from '../../../../components/customs/Loader';
import { notify } from '../../../../services/toast';
interface VariantsTabProps {
productId?: string;
@@ -14,6 +15,8 @@ interface VariantsTabProps {
parentSku: string;
family: any; // Product Family details
readOnly?: boolean;
productAttributes?: Record<string, any>;
availableAttributes?: any[];
}
export const VariantsTab: React.FC<VariantsTabProps> = ({
@@ -21,7 +24,9 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
productType,
parentSku,
family,
readOnly
readOnly,
productAttributes = {},
availableAttributes = []
}) => {
const {
variants,
@@ -39,6 +44,24 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
const [showGenerator, setShowGenerator] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Local state for dynamically configured variant axes (flows from family, reconstructed from variants, or custom selected)
const [localAxes, setLocalAxes] = useState<VariantAxis[]>([]);
// Filter attributes that are marked as variant eligible OR are of eligible types (including type 'color')
const selectableAttributes = useMemo(() => {
return availableAttributes.filter(attr =>
attr.is_variant_eligible === true ||
attr.isVariantEligible === true ||
['select', 'enumeration', 'swatch', 'multiselect', 'color'].includes(attr.type || '')
);
}, [availableAttributes]);
// Form states for axis addition
const [selectedAttrId, setSelectedAttrId] = useState('');
const [customAxisName, setCustomAxisName] = useState('');
const [customAxisCode, setCustomAxisCode] = useState('');
const [isAxesConfigOpen, setIsAxesConfigOpen] = useState(false);
// Load existing variants if product is created
useEffect(() => {
if (productId) {
@@ -46,34 +69,180 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
}, [productId, fetchByProduct]);
// Extract configured axes from the family
const variantAxes: VariantAxis[] = useMemo(() => {
if (!family || !Array.isArray(family.variantAxes)) return [];
return family.variantAxes;
}, [family]);
// Resolve relevant variant axes for this product (prioritized hierarchy: family axes -> existing variants -> product attributes with values)
useEffect(() => {
const axesMap = new Map<string, VariantAxis>();
const axesKeys = useMemo(() => variantAxes.map(a => a.code), [variantAxes]);
// Priority 1: Family blueprint variant axes (if explicitly configured)
if (family && Array.isArray(family.variantAxes) && family.variantAxes.length > 0) {
family.variantAxes.forEach((fa: any) => axesMap.set(fa.code, fa));
}
// Priority 2: Existing variants' actual attribute keys
if (variants.length > 0) {
variants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(key => {
if (!axesMap.has(key)) {
const foundAttr = selectableAttributes.find(a => a.code === key);
axesMap.set(key, {
id: foundAttr?.id || key,
code: key,
name: foundAttr?.name || key.toUpperCase().replace(/_VARIANT/g, '').replace(/_/g, ' '),
type: foundAttr?.type || 'select',
optionsList: foundAttr?.optionsList || []
});
}
});
}
});
}
// Priority 3: Only if no axes found yet, look at product attributes that have values set
if (axesMap.size === 0 && productAttributes && selectableAttributes.length > 0) {
selectableAttributes.forEach(attr => {
const val = productAttributes[attr.code];
if (val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)) {
if (!axesMap.has(attr.code)) {
axesMap.set(attr.code, {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
});
}
}
});
}
// Preserve manually added local axes
localAxes.forEach(la => {
if (!axesMap.has(la.code)) {
axesMap.set(la.code, la);
}
});
const resolved = Array.from(axesMap.values());
const currentKeys = localAxes.map(la => la.code).sort().join(',');
const newKeys = resolved.map(r => r.code).sort().join(',');
if (currentKeys !== newKeys && resolved.length > 0) {
setLocalAxes(resolved);
}
}, [family, variants, selectableAttributes, productAttributes]);
const handleAddAttributeAxis = () => {
if (!selectedAttrId) return;
const attr = selectableAttributes.find(a => a.id === selectedAttrId);
if (!attr) return;
if (localAxes.some(la => la.code === attr.code)) {
notify.error(`Axis with code "${attr.code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
...attr,
id: attr.id,
code: attr.code,
name: attr.name,
type: attr.type || 'select',
optionsList: attr.optionsList || []
};
setLocalAxes(prev => [...prev, newAxis]);
setSelectedAttrId('');
notify.success(`Added axis: ${attr.name}`);
};
const handleAddCustomAxis = () => {
const name = customAxisName.trim();
let code = customAxisCode.trim().toLowerCase().replace(/[^a-z0-9]/g, '_');
if (!name) {
notify.error('Please enter a name for the custom axis.');
return;
}
if (!code) {
code = name.toLowerCase().replace(/[^a-z0-9]/g, '_');
}
if (localAxes.some(la => la.code === code)) {
notify.error(`Axis with code "${code}" is already added.`);
return;
}
const newAxis: VariantAxis = {
id: `custom-${Date.now()}`,
code,
name,
type: 'select',
optionsList: []
};
setLocalAxes(prev => [...prev, newAxis]);
setCustomAxisName('');
setCustomAxisCode('');
notify.success(`Added custom axis: ${name}`);
};
const handleRemoveAxis = (code: string) => {
setLocalAxes(prev => prev.filter(la => la.code !== code));
notify.info(`Removed axis: ${code}`);
};
const initialSelectedValues = useMemo(() => {
const map: Record<string, string[]> = {};
if (!productAttributes) return map;
localAxes.forEach(axis => {
const val = productAttributes[axis.code];
if (val !== undefined && val !== null && val !== '') {
if (Array.isArray(val)) {
map[axis.code] = val.map(String);
} else if (typeof val === 'string' && val.includes(',')) {
map[axis.code] = val.split(',').map(s => s.trim());
} else {
map[axis.code] = [String(val)];
}
}
});
return map;
}, [localAxes, productAttributes]);
// Filter out unconfigured simple master variants (0 attributes) and ensure strict parent productId matching
const configuredVariants = useMemo(() => {
if (!Array.isArray(variants) || !productId) return [];
return variants.filter(v => {
if (!v) return false;
const vParentId = v.parentProductId || (v as any).product_id || (v as any).productId;
if (vParentId && vParentId !== productId) return false;
return v.attributes && typeof v.attributes === 'object' && Object.keys(v.attributes).length > 0;
});
}, [variants, productId]);
// Derive axesKeys dynamically from actual configured variants if present, or fallback to localAxes
const axesKeys = useMemo(() => {
const keysSet = new Set<string>();
configuredVariants.forEach(v => {
if (v.attributes) {
Object.keys(v.attributes).forEach(k => keysSet.add(k));
}
});
if (keysSet.size > 0) {
return Array.from(keysSet);
}
return (localAxes || []).map(a => a.code);
}, [configuredVariants, localAxes]);
const axesNames = useMemo(() => {
const map: Record<string, string> = {};
variantAxes.forEach(a => {
(localAxes || []).forEach(a => {
map[a.code] = a.name;
});
return map;
}, [variantAxes]);
// If the family does not support variants
if (variantAxes.length === 0) {
return (
<div className="p-8 text-center bg-surface rounded-xl border border-border shadow-sm">
<Layers className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
<h3 className="font-semibold text-foreground mb-1">This Product Family does not support variants.</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
</p>
</div>
);
}
}, [localAxes]);
// If the product is not Configurable (type !== 'variant')
if (productType !== 'variant') {
@@ -103,7 +272,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
const handleGenerate = async (selected: Record<string, string[]>, skuTemplate: string) => {
const formattedAxes = Object.entries(selected).map(([code, values]) => {
const axisInfo = variantAxes.find(a => a.code === code);
const axisInfo = localAxes.find(a => a.code === code);
return {
code,
name: axisInfo?.name || code,
@@ -119,7 +288,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
});
setShowGenerator(false);
} catch (err) {
// toast notification is done inside the hook
// handled in hook
}
};
@@ -143,14 +312,13 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
}
};
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; stock?: number; status?: VariantStatus }) => {
const handleBulkUpdates = async (updates: { price?: number; costPrice?: number; status?: VariantStatus }) => {
try {
const ids = Array.from(selectedIds);
await bulkUpdate({
ids,
updates
});
// Refresh items
fetchByProduct(productId);
setSelectedIds(new Set());
} catch (err) {}
@@ -176,6 +344,101 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
return updateVariant(id, updates);
};
const renderAxisCreatorControls = () => {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
{/* Option A: Choose from Attribute Set */}
{selectableAttributes.length > 0 && (
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">A. Choose from Attribute Set</h4>
<p className="text-[11px] text-muted-foreground mt-1">Designate a dropdown/select attribute from your assigned Attribute Set.</p>
</div>
<div className="flex gap-2">
<select
value={selectedAttrId}
onChange={(e) => setSelectedAttrId(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface font-medium text-foreground"
>
<option value="">Select Attribute...</option>
{selectableAttributes.map(attr => (
<option key={attr.id} value={attr.id}>
{attr.name} ({attr.code})
</option>
))}
</select>
<button
type="button"
onClick={handleAddAttributeAxis}
disabled={!selectedAttrId}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Axis
</button>
</div>
</div>
)}
{/* Option B: Create Custom Axis */}
<div className="border border-border/80 rounded-xl p-5 bg-background/25 space-y-4">
<div>
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">B. Create Custom Axis</h4>
<p className="text-[11px] text-muted-foreground mt-1">Create a custom variant axis not present in the attribute set (e.g. Size, Color).</p>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Axis Name (e.g. Size)"
value={customAxisName}
onChange={(e) => setCustomAxisName(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
<input
type="text"
placeholder="Axis Code (e.g. size)"
value={customAxisCode}
onChange={(e) => setCustomAxisCode(e.target.value)}
className="w-full text-xs border border-border rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-surface text-foreground"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleAddCustomAxis}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
>
Add Custom Axis
</button>
</div>
</div>
</div>
{/* List of currently added local axes */}
{localAxes.length > 0 && (
<div className="border border-border rounded-xl p-5 bg-background/10 space-y-3">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Designated Variant Axes ({localAxes.length})</h4>
<div className="flex flex-wrap gap-2">
{localAxes.map(axis => (
<span key={axis.code} className="inline-flex items-center gap-1.5 px-3 py-1 bg-surface border border-border rounded-lg text-xs font-semibold text-foreground">
{axis.name} <span className="text-muted-foreground font-mono">({axis.code})</span>
<button
type="button"
onClick={() => handleRemoveAxis(axis.code)}
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded transition-colors text-muted-foreground"
title="Remove axis"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
</div>
)}
</div>
);
};
// Loading spinner for variant fetching
if (loading && variants.length === 0) {
return (
@@ -185,26 +448,64 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
);
}
// Show generator UI if variants list is empty or generator toggled on
if (variants.length === 0 || showGenerator) {
// If local variant axes list is empty, they must add at least one axis
if (localAxes.length === 0) {
return (
<div className="space-y-4 bg-surface rounded-xl border border-border p-6 shadow-sm">
<div className="border-b border-border pb-4">
<div className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Configure Variant Axes</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
This product has no variant axes defined. Designate attributes from your Attribute Set or add custom ones to enable variant generation.
</p>
</div>
{renderAxisCreatorControls()}
</div>
);
}
// Show generator UI if configured variants list is empty or generator toggled on
if (configuredVariants.length === 0 || showGenerator) {
return (
<div className="space-y-4">
{variants.length > 0 && (
<div className="flex justify-start">
<div className="flex justify-between items-center gap-4 flex-wrap">
<div className="flex gap-2">
{configuredVariants.length > 0 && (
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
>
Cancel and view variants
</button>
)}
<button
type="button"
onClick={() => setShowGenerator(false)}
className="px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold"
onClick={() => setIsAxesConfigOpen(!isAxesConfigOpen)}
className="px-3 py-1.5 bg-surface hover:bg-background border border-border text-foreground rounded-lg text-xs font-semibold flex items-center gap-1.5"
>
Cancel and view variants
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
{isAxesConfigOpen ? 'Hide Axes Config' : 'Configure Variant Axes'}
</button>
</div>
</div>
{isAxesConfigOpen && (
<div className="bg-surface rounded-xl border border-border p-5 shadow-xs space-y-4">
<h4 className="font-semibold text-foreground text-xs uppercase tracking-wider">Configure Variant Axes</h4>
{renderAxisCreatorControls()}
</div>
)}
<VariantAxesSelector
axes={variantAxes}
axes={localAxes}
onGenerate={handleGenerate}
generating={generating}
parentSku={parentSku}
initialSelectedValues={initialSelectedValues}
/>
</div>
);
@@ -275,7 +576,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
{/* Primary variants view switcher */}
{viewLayout === 'matrix' ? (
<VariantMatrixView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
axesNames={axesNames}
selectedIds={selectedIds}
@@ -288,7 +589,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
/>
) : (
<VariantListView
variants={variants}
variants={configuredVariants}
axesKeys={axesKeys}
selectedIds={selectedIds}
onSelectChange={handleSelectChange}
@@ -66,10 +66,30 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
}
setAttributes(flatAttrs);
// Hydrate variant axes to ensure full attribute objects with .code and .name exist
const allAttrsMap = new Map<string, any>(flatAttrs.map(a => [a.id, a]));
// Hydrate variant axes to ensure full attribute objects (including
// selectable options) are used. Family blueprints commonly expose only
// axis UUIDs, while the complete attributes live inside the hydrated
// Attribute Set groups.
const setAttrs: any[] = [];
if (Array.isArray(setObj?.groups)) {
setObj.groups.forEach((group: any) => {
if (Array.isArray(group.attributes)) setAttrs.push(...group.attributes);
});
}
const allAttrsMap = new Map<string, any>();
[...flatAttrs, ...setAttrs].forEach((raw: any) => {
const attr = raw?.attribute || raw;
[raw?.id, raw?._id, raw?.attribute_id, attr?.id, attr?._id, attr?.code]
.filter(Boolean)
.forEach((key: any) => allAttrsMap.set(String(key), attr));
});
const resolvedVariantAxes = Array.isArray(blueprint.variantAxes)
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
? blueprint.variantAxes.map((va: any) => {
const key = typeof va === 'string'
? va
: (va?.attribute_id || va?.id || va?._id || va?.code);
return (key && allAttrsMap.get(String(key))) || va;
})
: [];
const normalizedBlueprint = {
@@ -81,7 +101,11 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setFamily(normalizedBlueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
// Preserve the hydrated attribute set when the family blueprint only
// exposes attributeSetId/attribute_set_id instead of an embedded object.
// Re-reading blueprint.attributeSet here used to overwrite the fetched
// set with null, so product creation lost the family's inheritance.
setAttributeSet(setObj || null);
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
+4
View File
@@ -14,6 +14,10 @@ export const useVariant = () => {
const [generating, setGenerating] = useState(false);
const fetchByProduct = useCallback(async (productId: string) => {
if (!productId || productId === 'new' || productId === 'null' || productId === 'undefined') {
setVariants([]);
return;
}
setLoading(true);
try {
const data = await variantService.getByProduct(productId);
+140 -46
View File
@@ -12,6 +12,7 @@ import { VariantsTab } from '../components/variants/VariantsTab';
import { DynamicAttributesSection } from '../components/DynamicAttributesSection';
import { ProductAssetsTab } from '../components/ProductAssetsTab';
import { useChannel } from '../../channels/hook/useChannel';
import type { Channel } from '../../channels/types/channels.types';
import { notify } from '../../../services/toast';
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
import { useAttribute } from '../../attributes/hook/useAttribute';
@@ -68,6 +69,33 @@ export default function NewProduct() {
const attributeSearchDropdownRef = useRef<HTMLDivElement>(null);
const [customAddedAttributes, setCustomAddedAttributes] = useState<any[]>([]);
const handleRemoveCustomAttribute = (attrId: string) => {
const attr = customAddedAttributes.find(a => a.id === attrId);
setCustomAddedAttributes(prev => prev.filter(a => a.id !== attrId));
if (attr && attr.code) {
formik.setFieldValue(`attributes.${attr.code}`, undefined);
}
};
const [excludedGroupIds, setExcludedGroupIds] = useState<Set<string>>(new Set());
const [productType, setProductType] = useState('simple');
const handleRemoveGroup = (groupId: string) => {
setExcludedGroupIds(prev => {
const next = new Set(prev);
next.add(String(groupId));
return next;
});
const group = filteredAttributeGroups.find((g: any) => String(g.id || g._id) === String(groupId));
if (group && Array.isArray(group.attributes)) {
group.attributes.forEach((attr: any) => {
if (attr && attr.code) {
formik.setFieldValue(`attributes.${attr.code}`, undefined);
}
});
}
};
const [newlyCreatedBrandIds, setNewlyCreatedBrandIds] = useState<string[]>([]);
const [newlyCreatedUnitIds, setNewlyCreatedUnitIds] = useState<string[]>([]);
@@ -127,10 +155,10 @@ export default function NewProduct() {
}, [attributeSetsList, attributeSetSearchQuery]);
const displayChannelsList = useMemo(() => {
const defaults = [
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active' },
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active' },
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active' }
const defaults: Channel[] = [
{ id: 'ch-shopify', name: 'Shopify Storefront', code: 'shopify', description: 'Direct Shopify e-commerce catalog sync', status: 'active', createdAt: '' },
{ id: 'ch-amazon', name: 'Amazon Marketplace', code: 'amazon', description: 'Amazon seller central product listings', status: 'active', createdAt: '' },
{ id: 'ch-custom-csv', name: 'Custom CSV Feed', code: 'custom_csv', description: 'Exportable CSV/XML syndication pipeline feed', status: 'active', createdAt: '' }
];
if (!allChannels || allChannels.length === 0) return defaults;
const merged = [...allChannels];
@@ -219,6 +247,7 @@ export default function NewProduct() {
const handleAttributeSetChange = async (setId: string) => {
setSelectedAttributeSetId(setId || null);
setExcludedGroupIds(new Set());
if (!setId) {
setSelectedAttributeSetObj(null);
setCustomAddedAttributes([]);
@@ -242,12 +271,17 @@ export default function NewProduct() {
};
// Resolve list of brands based on allowed list
const activeAllowedBrandsList = useMemo(() => {
if (familyBrands && familyBrands.length > 0) {
return brands.filter(b =>
newlyCreatedBrandIds.includes(b.id) ||
familyBrands.some((fb: any) => (typeof fb === 'string' ? fb === b.id : fb?.id === b.id))
familyBrands.some((fb: any) => {
if (typeof fb === 'string') {
const val = fb.toLowerCase().trim();
return val === b.id || val === (b.code || '').toLowerCase().trim() || val === (b.name || '').toLowerCase().trim();
}
return fb?.id === b.id || (fb?.code || '').toLowerCase().trim() === (b.code || '').toLowerCase().trim();
})
);
}
return brands;
@@ -345,19 +379,36 @@ export default function NewProduct() {
return groupsCopy;
}, [activeAttributeGroups, customAddedAttributes]);
const variantAxesCodes = useMemo(() => {
if (!family || !Array.isArray(family.variantAxes)) return [];
return family.variantAxes.map((a: any) => (a.code || '').toLowerCase().trim());
}, [family]);
const filteredAttributeGroups = useMemo(() => {
if (!unifiedAttributeGroups) return [];
const isVariableProduct = productType === 'variant';
return unifiedAttributeGroups.map((group: any) => ({
...group,
id: group.id || group._id,
attributes: (group.attributes || []).filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase()))
})).filter((group: any) => (group.attributes || []).length > 0);
}, [unifiedAttributeGroups]);
attributes: (group.attributes || []).filter((attr: any) => {
const codeLower = (attr.code || '').toLowerCase().trim();
if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false;
if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false;
return true;
})
})).filter((group: any) => (group.attributes || []).length > 0 && !excludedGroupIds.has(String(group.id || group._id)));
}, [unifiedAttributeGroups, excludedGroupIds, variantAxesCodes, productType]);
const filteredAttributesList = useMemo(() => {
if (!activeAttributesList) return [];
return activeAttributesList.filter((attr: any) => !EXCLUDED_ATTRIBUTE_CODES.includes((attr.code || '').toLowerCase()));
}, [activeAttributesList]);
const isVariableProduct = productType === 'variant';
return activeAttributesList.filter((attr: any) => {
const codeLower = (attr.code || '').toLowerCase().trim();
if (EXCLUDED_ATTRIBUTE_CODES.includes(codeLower)) return false;
if (isVariableProduct && variantAxesCodes.includes(codeLower)) return false;
return true;
});
}, [activeAttributesList, variantAxesCodes, productType]);
const filteredRegistryAttributes = useMemo(() => {
if (!allRegistryAttributes) return [];
@@ -614,6 +665,12 @@ export default function NewProduct() {
}
});
useEffect(() => {
if (formik.values.type !== productType) {
setProductType(formik.values.type);
}
}, [formik.values.type, productType]);
const areRequiredAttributesComplete = useMemo(() => {
if (!Array.isArray(filteredAttributesList)) return true;
return !filteredAttributesList.some((attr: any) => {
@@ -628,7 +685,11 @@ export default function NewProduct() {
const currentTabs = useMemo(() => {
const isVariant = formik.values.type === 'variant';
const assetsCount = product?.productAssets?.length ?? 0;
const productAssetsCount = product?.productAssets?.length ?? 0;
const variantAssetsCount = (product?.variants || []).reduce(
(sum: number, v: any) => sum + (v.images?.length ?? 0), 0
);
const assetsCount = productAssetsCount + variantAssetsCount;
const tabsList = [
{ id: 'general', label: 'General', icon: Box },
{ id: 'attributes', label: 'Attributes', icon: LayoutGrid },
@@ -638,7 +699,7 @@ export default function NewProduct() {
{ id: 'review', label: 'Review', icon: Eye },
];
return tabsList.map((t, idx) => ({ ...t, step: idx + 1 }));
}, [formik.values.type, product?.productAssets]);
}, [formik.values.type, product?.productAssets, product?.variants]);
// Redirect if current activeTab is not in available tabs list (e.g. Variants removed)
useEffect(() => {
@@ -891,7 +952,9 @@ export default function NewProduct() {
score += 20;
}
if (product?.productAssets && product.productAssets.length > 0) {
const hasProductAssets = product?.productAssets && product.productAssets.length > 0;
const hasVariantAssets = (product?.variants || []).some((v: any) => (v.images && v.images.length > 0) || (v.variantAssets && v.variantAssets.length > 0));
if (hasProductAssets || hasVariantAssets) {
score += 15;
}
@@ -1302,13 +1365,13 @@ export default function NewProduct() {
stroke="var(--color-primary)"
strokeWidth="10"
strokeDasharray="283"
strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness)) / 100}
strokeDashoffset={283 - (283 * (product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness)) / 100}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-sm font-bold text-foreground">
{product?.completeness !== undefined && product?.completeness !== null && product.completeness > 0 ? product.completeness : productCompleteness}%
{product?.completeness !== undefined && product?.completeness !== null ? product.completeness : productCompleteness}%
</span>
</div>
</div>
@@ -1316,9 +1379,14 @@ export default function NewProduct() {
<div className="space-y-1.5 text-[11px]">
{[
{ label: 'Variants', value: String(product?.variants?.length || 0) },
{ label: 'Assets', value: String(product?.productAssets?.length || 0) },
{
label: 'Assets', value: String(
(product?.productAssets?.length || 0) +
(product?.variants || []).reduce((s: number, v: any) => s + (v.images?.length ?? 0), 0)
)
},
{ label: 'Category', value: formik.values.category ? '1' : '0' },
{ label: 'Channels', value: String(product?.metadata?.channels?.length || 0) },
{ label: 'Channels', value: String((formik.values.metadata?.channels?.length || 0) + (family?.channels?.length || 0)) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span>
@@ -1548,10 +1616,7 @@ export default function NewProduct() {
<label className={labelClass}>Price ($)</label>
<input name="price" type="number" step="0.01" value={formik.values.price} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0.00" className={inputClass} disabled={isReadOnlyView} />
</div>
<div>
<label className={labelClass}>Initial Stock</label>
<input name="stock" type="number" value={formik.values.stock} onChange={formik.handleChange} onBlur={formik.handleBlur} placeholder="0" className={inputClass} disabled={isReadOnlyView} />
</div>
<div>
<label className={labelClass}>SKU</label>
<input name="sku" value={formik.values.sku} onChange={formik.handleChange} placeholder="Stock Keeping Unit" className={inputClass} disabled={isReadOnlyView} />
@@ -1747,7 +1812,18 @@ export default function NewProduct() {
{/* Attribute Set dropdown */}
<div className="flex-1 min-w-[280px] max-w-md">
<label className="block text-xs font-semibold text-muted-foreground mb-1.5">Attribute Set</label>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-semibold text-muted-foreground">Attribute Set</label>
{selectedAttributeSetId && !isReadOnlyView && !(family && family.attributeSet) && (
<button
type="button"
onClick={() => handleAttributeSetChange('')}
className="text-[11px] text-red-500 hover:text-red-600 font-semibold transition-colors cursor-pointer"
>
Remove Set
</button>
)}
</div>
{/* Searchable Attribute Set Selector */}
<div ref={attributeSetDropdownRef} className="relative">
@@ -1928,6 +2004,9 @@ export default function NewProduct() {
}}
onAttributeBlur={(code) => formik.setFieldTouched(`attributes.${code}`, true)}
onAddAttributeClick={(group) => handleOpenCreateAttributeModal(group.id || group._id)}
onRemoveAttribute={handleRemoveCustomAttribute}
onRemoveGroup={handleRemoveGroup}
customAttributeIds={new Set(customAddedAttributes.map(a => a.id))}
readOnly={isReadOnlyView}
/>
)}
@@ -1947,6 +2026,8 @@ export default function NewProduct() {
parentSku={formik.values.sku}
family={family}
readOnly={isReadOnlyView}
productAttributes={formik.values.attributes}
availableAttributes={activeAttributesList}
/>
)
)}
@@ -2003,8 +2084,8 @@ export default function NewProduct() {
type="checkbox"
checked={isSelected}
disabled={isInherited || isReadOnlyView}
onChange={() => { }} // Handled by outer card click
onClick={(e) => e.stopPropagation()} // Prevent double triggers
onChange={() => !isInherited && !isReadOnlyView && handleChannelToggle(ch.code)}
onClick={(e) => e.stopPropagation()} // Prevent the card from toggling twice
className={`w-4 h-4 text-primary rounded border-border focus:ring-primary ${isInherited ? 'cursor-not-allowed opacity-75' : ''
}`}
/>
@@ -2074,12 +2155,7 @@ export default function NewProduct() {
{formik.values.price !== '' ? `$${formik.values.price}` : (product?.price ? `$${product.price}` : '—')}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Initial Stock</div>
<div className="font-semibold text-sm text-foreground">
{formik.values.stock !== undefined ? formik.values.stock : (product?.stock ?? 0)} pcs
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Unit of Measure</div>
<div className="font-medium text-sm text-foreground">
@@ -2221,7 +2297,7 @@ export default function NewProduct() {
<div className="divide-y divide-border">
{variants.slice(0, VARIANT_PREVIEW).map((v: any, idx: number) => {
const axes = (v.values || [])
.map((vv: any) => `${vv.axis?.name || vv.attribute_code || 'Axis'}: ${vv.value}`)
.map((vv: any) => `${vv.axis?.name || vv.attribute_code || 'Axis'}: ${vv.value ?? vv.value_text ?? '—'}`)
.join(' · ');
const sku = v.metadata?.sku || v.sku || null;
return (
@@ -2246,8 +2322,21 @@ export default function NewProduct() {
{/* ── Assets ── */}
{(() => {
const assets = product?.productAssets || [];
const ASSET_PREVIEW = 6;
const globalAssets = (product?.productAssets || []).map((pa: any) => ({
...pa,
scope: 'Global'
}));
const variantAssetsList = (product?.variants || []).flatMap((v: any) =>
(v.images || []).map((img: any) => ({
id: img.assetId || img.url,
asset: img,
role: img.role,
is_primary: img.isPrimary,
scope: `Variant: ${v.name?.split(' - ')[1] || v.name || v.sku}`
}))
);
const allReviewAssets = [...globalAssets, ...variantAssetsList];
const ASSET_PREVIEW = 8;
return (
<div className="bg-surface border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
@@ -2255,7 +2344,7 @@ export default function NewProduct() {
<ImageIcon className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-sm text-foreground">Assets</h3>
<span className="text-xs text-muted-foreground font-medium">
{assets.length > 0 ? `${assets.length} uploaded` : 'None uploaded'}
{allReviewAssets.length > 0 ? `${allReviewAssets.length} uploaded` : 'None uploaded'}
</span>
</div>
<button
@@ -2266,15 +2355,15 @@ export default function NewProduct() {
<Pencil className="w-3 h-3" /> Edit
</button>
</div>
{assets.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{assets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => {
{allReviewAssets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{allReviewAssets.slice(0, ASSET_PREVIEW).map((pa: any, idx: number) => {
const asset = pa.asset || pa;
const thumb = asset.thumbnail_url || asset.url || null;
const thumb = asset.thumbnail_url || asset.thumbnailUrl || asset.file_url || asset.url || null;
const name = asset.name || asset.original_name || `Asset ${idx + 1}`;
const typeName = asset.assetType?.name || pa.asset_type || null;
const role = pa.role || null;
const isPrimary = pa.is_primary;
const isPrimary = pa.is_primary || pa.isPrimary;
const scope = pa.scope || 'Global';
return (
<div key={pa.id || idx} className="flex items-center gap-3 p-2.5 border border-border rounded-lg bg-background">
{thumb ? (
@@ -2292,16 +2381,19 @@ export default function NewProduct() {
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
{role ? role.replace('_', ' ') : 'HERO IMAGE'}
<span className="text-[9px] font-bold text-primary bg-primary/10 border border-primary/20 px-1.5 py-0.5 rounded uppercase">
{role ? String(role).replace('_', ' ') : 'MEDIA'}
</span>
<span className="text-[9px] font-medium text-muted-foreground bg-surface-muted border border-border px-1.5 py-0.5 rounded truncate">
{scope}
</span>
</div>
</div>
</div>
);
})}
{assets.length > ASSET_PREVIEW && (
<div className="text-xs text-muted-foreground text-center pt-1">+{assets.length - ASSET_PREVIEW} more assets</div>
{allReviewAssets.length > ASSET_PREVIEW && (
<div className="col-span-2 text-xs text-muted-foreground text-center pt-1">+{allReviewAssets.length - ASSET_PREVIEW} more assets</div>
)}
</div>
) : (
@@ -2352,7 +2444,9 @@ export default function NewProduct() {
{/* ── Channels ── */}
{(() => {
const inheritedCodes = (family?.channels || []).map((fc: any) => fc.channel_code);
const inheritedCodes = (family?.channels || [])
.map((fc: any) => typeof fc === 'string' ? fc : (fc.channel_code || fc.code))
.filter(Boolean);
const optionalCodes = (formik.values.metadata?.channels || []).filter(
(c: string) => !inheritedCodes.includes(c)
);
+1 -1
View File
@@ -66,7 +66,7 @@ export default function ProductList() {
// Stats for KPI cards
const stats = useMemo(() => {
const published = products.filter(p => p.status === "published").length;
const published = products.filter(p => p.status === "published" || p.status === "active").length;
const pending = products.filter(p => p.status === "pending").length;
const draft = products.filter(p => p.status === "draft").length;
const avgCompleteness = products.length > 0
@@ -2,13 +2,14 @@
import { Routes, Route } from 'react-router-dom';
import ProductList from '../pages/ProductList';
import NewProduct from '../pages/NewProduct';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const ProductRoutes = () => {
return (
<Routes>
<Route index element={<ProductList />} /> {/* /products */}
<Route path="new" element={<NewProduct />} /> {/* /products/new */}
<Route path=":id/edit" element={<NewProduct />} /> {/* /products/123/edit */}
<Route path="new" element={<ProtectedRoute node="products.items" action="create"><NewProduct /></ProtectedRoute>} /> {/* /products/new */}
<Route path=":id/edit" element={<ProtectedRoute node="products.items" action="edit"><NewProduct /></ProtectedRoute>} /> {/* /products/123/edit */}
<Route path=":id" element={<NewProduct />} /> {/* /products/123 */}
</Routes>
);
+4 -1
View File
@@ -47,16 +47,19 @@ export interface Variant {
barcode?: string;
weight?: number;
dimensions?: { length?: number; width?: number; height?: number };
images?: VariantImageSlot[];
images: VariantImageSlot[];
}
// ─── Image slot (prepared for Phase 3 DAM integration) ────────────────────
export interface VariantImageSlot {
assetId?: string;
url?: string;
thumbnailUrl?: string;
name?: string;
role: 'primary' | 'gallery' | 'swatch';
isPrimary: boolean;
displayOrder: number;
assetType?: { id: string; code: string; name: string } | null;
}
// ─── Axis configuration for batch generation ──────────────────────────────
+2 -2
View File
@@ -8,8 +8,8 @@ export const RoleRoutes = () => {
<ProtectedRoute node="settings.roles">
<Routes>
<Route path="/" element={<RoleList />} />
<Route path="/new" element={<NewRoleForm />} />
<Route path="/:id/edit" element={<NewRoleForm />} />
<Route path="/new" element={<ProtectedRoute node="settings.roles" action="create"><NewRoleForm /></ProtectedRoute>} />
<Route path="/:id/edit" element={<ProtectedRoute node="settings.roles" action="edit"><NewRoleForm /></ProtectedRoute>} />
</Routes>
</ProtectedRoute>
);
+4 -4
View File
@@ -108,10 +108,10 @@ export default function SettingList() {
const cat = activeTab.toLowerCase();
const data = await settingsService.getCategorySettings(cat);
if (data) {
if (data.orgName) setOrgName(data.orgName);
if (data.subdomain) setSubdomain(data.subdomain);
if (data.requireApproval !== undefined) setRequireApproval(data.requireApproval);
if (data.autoPublish !== undefined) setAutoPublish(data.autoPublish);
if (typeof data.orgName === 'string') setOrgName(data.orgName);
if (typeof data.subdomain === 'string') setSubdomain(data.subdomain);
if (typeof data.requireApproval === 'boolean') setRequireApproval(data.requireApproval);
if (typeof data.autoPublish === 'boolean') setAutoPublish(data.autoPublish);
}
} catch (err) {
// Silently fallback to defaults
@@ -1,12 +1,37 @@
import axiosInstance from '../../../api/axiosInstance';
import apiClient from '../../../api/axiosInstance';
import type { Setting, SettingCreateRequest, SettingUpdateRequest } from '../types/settings.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const settingsService = {
getCategorySettings: async (category: string) => {
const response = await axiosInstance.get(`/settings/by-category/${category}`);
return response.data.data;
},
updateCategorySettings: async (category: string, data: Record<string, any>) => {
const response = await axiosInstance.put(`/settings/by-category/${category}`, data);
getAll: async (): Promise<Setting[]> => {
const response = await apiClient.get<ApiResponse<Setting[]>>('/api/v1/settings');
return response.data;
},
getById: async (id: string): Promise<Setting> => {
const response = await apiClient.get<ApiResponse<Setting>>(`/api/v1/settings/${id}`);
return response.data;
},
create: async (data: SettingCreateRequest): Promise<Setting> => {
const response = await apiClient.post<ApiResponse<Setting>>('/api/v1/settings', data);
return response.data;
},
update: async (id: string, data: SettingUpdateRequest): Promise<Setting> => {
const response = await apiClient.put<ApiResponse<Setting>>(`/api/v1/settings/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/api/v1/settings/${id}`);
},
getCategorySettings: async (category: string): Promise<Record<string, unknown>> => {
const response = await apiClient.get<ApiResponse<Record<string, unknown>>>(`/api/v1/settings/by-category/${category}`);
return response.data;
},
updateCategorySettings: async (category: string, data: Record<string, unknown>) => {
return apiClient.put<ApiResponse<Record<string, unknown>>>(`/api/v1/settings/by-category/${category}`, data);
}
};
+2
View File
@@ -118,6 +118,7 @@ export function useTenant() {
try {
const result = await tenantService.impersonateTenant(tenantId);
localStorage.setItem('impersonatedTenantId', tenantId);
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: tenantId }));
notify.success(result.message || 'Support impersonation active');
return result;
} catch (err) {
@@ -128,6 +129,7 @@ export function useTenant() {
const stopImpersonation = () => {
localStorage.removeItem('impersonatedTenantId');
window.dispatchEvent(new CustomEvent('pim:impersonation-changed', { detail: null }));
notify.info('Support impersonation ended');
};
+3 -2
View File
@@ -1,13 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import UnitList from '../pages/UnitList';
import NewUnit from '../pages/NewUnit';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const UnitRoutes = () => {
return (
<Routes>
<Route index element={<UnitList />} />
<Route path="new" element={<NewUnit />} />
<Route path=":id/edit" element={<NewUnit />} />
<Route path="new" element={<ProtectedRoute node="masters.units" action="create"><NewUnit /></ProtectedRoute>} />
<Route path=":id/edit" element={<ProtectedRoute node="masters.units" action="edit"><NewUnit /></ProtectedRoute>} />
<Route path=":id/view" element={<NewUnit />} />
</Routes>
);
+50 -23
View File
@@ -14,6 +14,7 @@ import type { DBRole } from "../services/roles.service";
import type { Tenant } from "../../tenants/types/tenant.types";
import { Save, AlertCircle, CheckCircle2, Edit2, ArrowLeft } from "lucide-react";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useAppSelector } from "../../../store";
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
@@ -37,6 +38,10 @@ export default function NewUser() {
const isEdit = Boolean(id) && location.pathname.endsWith("/edit");
const isView = Boolean(id) && !isEdit;
const currentUser = useAppSelector((state) => state.auth.user);
const isPlatformUser = currentUser?.user_type === "platform" || currentUser?.type === "platform";
const currentTenantId = currentUser?.tenant_id || currentUser?.tenant?.id;
const currentTenantName = currentUser?.tenant?.name || currentUser?.tenant_name || `Tenant #${currentTenantId}`;
const [roles, setRoles] = useState<DBRole[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -46,14 +51,16 @@ export default function NewUser() {
const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
useEffect(() => {
tenantService.getAll().then(setTenants).catch(console.error);
}, []);
if (isPlatformUser) {
tenantService.getAll().then(setTenants).catch(console.error);
}
}, [isPlatformUser]);
const formik = useFormik({
initialValues: {
email: "",
roleId: "",
tenantId: "",
tenantId: isPlatformUser ? "" : String(currentTenantId || ""),
name: "",
phone: "",
status: "active" as "active" | "inactive",
@@ -108,14 +115,16 @@ export default function NewUser() {
useEffect(() => {
setRolesLoading(true);
const tenantId = formik.values.tenantId || null;
const tenantId = isPlatformUser
? (formik.values.tenantId || null)
: (String(currentTenantId || formik.values.tenantId) || null);
rolesService
.getByTenant(tenantId)
.then(setRoles)
.catch(console.error)
.finally(() => setRolesLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formik.values.tenantId]);
}, [formik.values.tenantId, isPlatformUser, currentTenantId]);
useEffect(() => {
if ((isEdit || isView) && id) {
@@ -322,22 +331,36 @@ export default function NewUser() {
</div>
<div className="md:col-span-1">
<label className={labelClass}>Select Tenant *</label>
<Select
name="tenantId"
value={formik.values.tenantId}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Select a Tenant..."
>
<option value="">Select a Tenant...</option>
<option value="platform">Platform Core (No Tenant)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</Select>
{formik.touched.tenantId && formik.errors.tenantId && (
<p className={errorClass}><AlertCircle className="w-3 h-3" />{formik.errors.tenantId}</p>
{isPlatformUser ? (
<>
<label className={labelClass}>Select Tenant *</label>
<Select
name="tenantId"
value={formik.values.tenantId}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Select a Tenant..."
>
<option value="">Select a Tenant...</option>
<option value="platform">Platform Core (No Tenant)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.tenant_name}</option>
))}
</Select>
{formik.touched.tenantId && formik.errors.tenantId && (
<p className={errorClass}><AlertCircle className="w-3 h-3" />{formik.errors.tenantId}</p>
)}
</>
) : (
<>
<label className={labelClass}>Tenant Organization</label>
<Input
readOnly
value={currentTenantName}
className="bg-background border-border font-semibold text-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">New members are automatically added to your organization.</p>
</>
)}
</div>
@@ -347,7 +370,11 @@ export default function NewUser() {
return (
<label className={labelClass}>
Assign Role *
{selTenant ? (
{!isPlatformUser ? (
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2.5 py-0.5 rounded-full font-medium">
Showing roles for {currentTenantName}
</span>
) : selTenant ? (
<span className="ml-2 text-xs font-normal text-primary bg-primary/5 px-2.5 py-0.5 rounded-full font-medium">
Showing roles for {selTenant.tenant_name}
</span>
@@ -375,7 +402,7 @@ export default function NewUser() {
<option key={r.id} value={r.id}>{r.role_name}</option>
))}
</Select>
{roles.length === 0 && !rolesLoading && formik.values.tenantId && (
{roles.length === 0 && !rolesLoading && (formik.values.tenantId || !isPlatformUser) && (
<p className="text-xs text-amber-600 mt-1">No roles found for this tenant. Please create a role for this tenant first.</p>
)}
{formik.touched.roleId && formik.errors.roleId && (
+3 -2
View File
@@ -1,13 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import UserList from '../pages/UserList';
import NewUser from '../pages/NewUser';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const UserRoutes = () => (
<Routes>
<Route index element={<UserList />} />
<Route path="new" element={<NewUser />} />
<Route path="new" element={<ProtectedRoute node="settings.users" action="create"><NewUser /></ProtectedRoute>} />
<Route path=":id" element={<NewUser />} />
<Route path=":id/view" element={<NewUser />} />
<Route path=":id/edit" element={<NewUser />} />
<Route path=":id/edit" element={<ProtectedRoute node="settings.users" action="edit"><NewUser /></ProtectedRoute>} />
</Routes>
);
@@ -50,6 +50,12 @@ export default function VariantList() {
},
{ key: "name", label: "Variant Name", sortable: true },
{ key: "parentProductName", label: "Product", sortable: true },
{
key: "tenantId",
label: "Tenant",
sortable: true,
render: (val: number | string | null) => val ? `Tenant #${val}` : "Platform Global"
},
{
key: "price",
label: "Price",
@@ -1,13 +1,14 @@
import { Routes, Route } from 'react-router-dom';
import VariantList from '../pages/VariantList';
import VariantDetail from '../pages/VariantDetail';
import { ProtectedRoute } from '../../../components/layouts/ProtectedRoute';
export const VariantRoutes = () => {
return (
<Routes>
<Route index element={<VariantList />} />
<Route path=":id" element={<VariantDetail />} />
<Route path=":id/edit" element={<VariantDetail />} />
<Route path=":id/edit" element={<ProtectedRoute node="products.variants" action="edit"><VariantDetail /></ProtectedRoute>} />
<Route path=":id/view" element={<VariantDetail />} />
</Routes>
);
@@ -5,6 +5,7 @@ export interface Variant {
sku: string;
parentProductId: string;
parentProductName: string;
tenantId?: number | string | null;
name: string;
attributes: Record<string, string>;
status: VariantStatus;
+1 -1
View File
@@ -9,7 +9,7 @@ import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</Provider>
+26 -21
View File
@@ -1,9 +1,10 @@
// src/routes/AppRoutes.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthGuard, PlatformGuard } from '../authentication/components/ProtectedRoute';
import { AuthGuard, PlatformGuard, TenantWorkspaceGuard } from '../authentication/components/ProtectedRoute';
import { ProtectedRoute } from '../components/layouts/ProtectedRoute';
import MainLayout from '../components/layouts/MainLayout';
import { Login, SignUp, ForgotPassword, ResetPassword, AcceptInvite } from '../authentication/routes';
import SSOCallback from '../authentication/pages/SSOCallback';
// Feature Routes
import { DashboardRoutes } from '../features/dashboard/routes/dashboard.routes';
@@ -44,6 +45,7 @@ const AppRoutes = () => {
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/sso/callback" element={<SSOCallback />} />
{/* Default Redirect */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
@@ -55,43 +57,46 @@ const AppRoutes = () => {
<Route path="/platform/overview" element={<PlatformGuard><PlatformOverview /></PlatformGuard>} />
<Route path="/platform/tenants" element={<PlatformGuard><PlatformTenantsPage /></PlatformGuard>} />
<Route element={<TenantWorkspaceGuard />}>
<Route path="/dashboard/*" element={<DashboardRoutes />} />
{/* Catalog */}
<Route path="/products/*" element={<ProductRoutes />} />
<Route path="/families/*" element={<FamilyRoutes />} />
<Route path="/categories/*" element={<CategoryRoutes />} />
<Route path="/variants/*" element={<VariantRoutes />} />
<Route path="/attributes/*" element={<AttributeRoutes />} />
<Route path="/attribute-groups/*" element={<AttributeGroupRoutes />} />
<Route path="/attribute-sets/*" element={<AttributeSetRoutes />} />
<Route path="/products/*" element={<ProtectedRoute node="products.items"><ProductRoutes /></ProtectedRoute>} />
<Route path="/families/*" element={<ProtectedRoute node="products.families"><FamilyRoutes /></ProtectedRoute>} />
<Route path="/categories/*" element={<ProtectedRoute node="products.categories"><CategoryRoutes /></ProtectedRoute>} />
<Route path="/variants/*" element={<ProtectedRoute node="products.variants"><VariantRoutes /></ProtectedRoute>} />
<Route path="/attributes/*" element={<ProtectedRoute node="products.attributes"><AttributeRoutes /></ProtectedRoute>} />
<Route path="/attribute-groups/*" element={<ProtectedRoute node="products.attributes"><AttributeGroupRoutes /></ProtectedRoute>} />
<Route path="/attribute-sets/*" element={<ProtectedRoute node="products.attributes"><AttributeSetRoutes /></ProtectedRoute>} />
{/* Masters */}
<Route path="/brands/*" element={<BrandRoutes />} />
<Route path="/units/*" element={<UnitRoutes />} />
<Route path="/brands/*" element={<ProtectedRoute node="masters.brands"><BrandRoutes /></ProtectedRoute>} />
<Route path="/units/*" element={<ProtectedRoute node="masters.units"><UnitRoutes /></ProtectedRoute>} />
{/* Assets */}
<Route path="/assets/*" element={<AssetRoutes />} />
<Route path="/asset-types/*" element={<AssetTypeRoutes />} />
<Route path="/asset-families/*" element={<AssetFamilyRoutes />} />
<Route path="/assets/*" element={<ProtectedRoute node="products.items"><AssetRoutes /></ProtectedRoute>} />
<Route path="/asset-types/*" element={<ProtectedRoute node="products.items"><AssetTypeRoutes /></ProtectedRoute>} />
<Route path="/asset-families/*" element={<ProtectedRoute node="products.items"><AssetFamilyRoutes /></ProtectedRoute>} />
{/* Operations */}
<Route path="/workflow/*" element={<WorkflowRoutes />} />
<Route path="/channels/*" element={<ChannelRoutes />} />
<Route path="/channel-types/*" element={<ChannelTypeRoutes />} />
<Route path="/integrations/*" element={<IntegrationRoutes />} />
<Route path="/workflow/*" element={<ProtectedRoute node="products.items"><WorkflowRoutes /></ProtectedRoute>} />
<Route path="/channels/*" element={<ProtectedRoute node="settings.integrations"><ChannelRoutes /></ProtectedRoute>} />
<Route path="/channel-types/*" element={<ProtectedRoute node="settings.integrations"><ChannelTypeRoutes /></ProtectedRoute>} />
<Route path="/integrations/*" element={<ProtectedRoute node="settings.integrations"><IntegrationRoutes /></ProtectedRoute>} />
{/* Users */}
<Route path="/users/tenants/*" element={<TenantRoutes />} />
<Route path="/users/roles/*" element={<ProtectedRoute node="settings.roles"><RoleRoutes /></ProtectedRoute>} />
<Route path="/users/*" element={<ProtectedRoute node="settings.users"><UserRoutes /></ProtectedRoute>} />
{/* Tenant reports are available to tenant users and audited Support Mode only. */}
<Route path="/reports/*" element={<ProtectedRoute node="reports"><ReportRoutes /></ProtectedRoute>} />
<Route path="/settings/*" element={<ProtectedRoute node="settings.users"><SettingRoutes /></ProtectedRoute>} />
</Route>
{/* Notifications */}
<Route path="/notifications/*" element={<NotificationRoutes />} />
<Route path="/notifications/*" element={<ProtectedRoute node="notifications"><NotificationRoutes /></ProtectedRoute>} />
{/* Admin */}
<Route path="/reports/*" element={<ReportRoutes />} />
<Route path="/settings/*" element={<SettingRoutes />} />
</Route>
{/* Catch-all */}
+4
View File
@@ -7,6 +7,10 @@ export interface RouteConfig {
}
export const protectedRoutes: RouteConfig[] = [
// SaaS control plane
{ path: '/platform/overview', title: 'SaaS Dashboard', description: 'Platform health, tenant growth, and aggregate usage' },
{ path: '/platform/tenants', title: 'Tenants', description: 'Provision and manage tenant organizations' },
// Dashboard
{ path: '/dashboard', title: 'Dashboard', description: 'Overview of your PIM platform' },
+29 -5
View File
@@ -5,7 +5,6 @@ import {
Grid3x3,
Tag,
Layers,
Database,
Ruler,
Award,
Image,
@@ -126,10 +125,10 @@ export const sidebarConfig: SidebarItem[] = [
label: 'Channels & Integration',
href: '/channels',
icon: Radio,
permission: 'channels.syndication',
permission: 'settings.integrations',
children: [
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'channels.syndication' },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'channels.syndication' },
{ label: 'Channel Registry', href: '/channels', icon: Radio, permission: 'settings.integrations' },
{ label: 'Channel Types', href: '/channel-types', icon: Layers2, permission: 'settings.integrations' },
{ label: 'Integration Hub', href: '/integrations', icon: Plug, permission: 'settings.integrations' },
]
},
@@ -151,11 +150,36 @@ export const sidebarConfig: SidebarItem[] = [
{
label: 'Settings',
href: '/settings',
icon: Settings
icon: Settings,
permission: 'settings.users'
},
{
label: 'Notifications',
href: '/notifications',
icon: Bell,
permission: 'notifications'
},
];
// Phase-1 SaaS control-plane navigation. Tenant catalog modules are exposed to
// platform operators only after they explicitly enter audited Support Mode.
export const platformSidebarConfig: SidebarItem[] = [
{
label: 'SaaS Dashboard',
href: '/platform/overview',
icon: LayoutDashboard,
platformOnly: true,
},
{
label: 'Tenants',
href: '/platform/tenants',
icon: Building2,
platformOnly: true,
},
{
label: 'Notifications',
href: '/notifications',
icon: Bell,
platformOnly: true,
},
];
+4 -1
View File
@@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
// Supports either a dedicated hostname or a reverse-proxy prefix such as
// `/pim_frontend/`. Vite requires a trailing slash for sub-path deployments.
base: process.env.VITE_BASE_PATH || '/',
plugins: [react(), tailwindcss()],
server: {
proxy: {
@@ -12,4 +15,4 @@ export default defineConfig({
},
},
},
})
})