Merge pull request #11 from MohamedHasan07/ali

Ali
This commit is contained in:
LIYAQATH ALI
2026-06-29 15:12:59 +05:30
committed by GitHub
28 changed files with 2529 additions and 994 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

@@ -0,0 +1,189 @@
import { ReactNode } from "react";
import { Database, GitBranch, Share2, ShieldCheck, Package } from "lucide-react";
import { HeroIllustration } from "./HeroIllustration";
interface AuthLayoutProps {
children: ReactNode;
}
const FEATURES = [
{ icon: Database, label: "Centralized Data", desc: "Single source of truth" },
{ icon: GitBranch, label: "Workflow Driven", desc: "Automate approvals" },
{ icon: Share2, label: "Multi-Channel", desc: "Publish everywhere" },
{ icon: ShieldCheck, label: "Secure & Scalable", desc: "Enterprise ready" },
];
export const AuthLayout = ({ children }: AuthLayoutProps) => {
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
.auth-wrap {
position: fixed; inset: 0;
display: flex; flex-direction: row;
font-family: 'Inter', system-ui, sans-serif;
background: linear-gradient(145deg, #f0eeff 0%, #ede9fe 40%, #e8e2ff 100%);
overflow: hidden;
}
/* ── Card entrance ── */
@keyframes cardIn {
from { opacity: 0; transform: translateX(32px) scale(0.97); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-card-in { animation: cardIn 0.8s 0.1s cubic-bezier(0.22,1,0.36,1) both; }
.auth-fade-up { animation: fadeUp 0.6s ease both; }
.auth-fade-up1 { animation: fadeUp 0.6s 0.12s ease both; }
.auth-fade-up2 { animation: fadeUp 0.6s 0.22s ease both; }
.auth-fade-up3 { animation: fadeUp 0.6s 0.34s ease both; }
/* ── Input ── */
.a-input {
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.a-input:focus {
border-color: #7c3aed !important;
box-shadow: 0 0 0 3px rgba(124,58,237,0.12);
}
/* ── Sign-in button ── */
.a-signin {
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
box-shadow: 0 4px 14px rgba(124,58,237,0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.a-signin:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(124,58,237,0.55);
}
.a-signin:active:not(:disabled) { transform: translateY(0); }
/* ── Social buttons ── */
.a-social {
border: 1.5px solid #e5e7eb;
background: white;
transition: background 0.18s, border-color 0.18s, transform 0.18s;
}
.a-social:hover {
background: #faf5ff;
border-color: #c4b5fd;
transform: translateY(-1px);
}
/* ── Scrollbar hide ── */
.a-right::-webkit-scrollbar { display: none; }
.a-right { -ms-overflow-style: none; scrollbar-width: none; }
`}</style>
<div className="auth-wrap">
{/* ═══════════════════════════════
LEFT PANEL (65%)
═══════════════════════════════ */}
<div style={{
width: "62%",
display: "flex", flexDirection: "column",
padding: "22px 28px 0 32px",
position: "relative", overflow: "hidden",
}}>
{/* Logo */}
<div className="auth-fade-up" style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
<div style={{
width: 36, height: 36, borderRadius: 9,
background: "linear-gradient(135deg, #7c3aed, #a78bfa)",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 4px 12px rgba(124,58,237,0.38)",
}}>
<Package size={18} color="white" />
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 800, color: "#1a1230", lineHeight: 1 }}>Product Catalog</div>
<div style={{ fontSize: 8.5, fontWeight: 600, color: "#7c3aed", letterSpacing: "0.14em", textTransform: "uppercase", marginTop: 2 }}>PMS / PIM</div>
</div>
</div>
{/* Hero heading */}
<div className="auth-fade-up1" style={{ marginTop: 18, flexShrink: 0 }}>
<h1 style={{
fontSize: "clamp(1.75rem, 2.8vw, 2.75rem)",
fontWeight: 900, color: "#0d0b1a",
lineHeight: 1.08, letterSpacing: "-0.03em", margin: 0,
}}>
<span style={{ display: "block", whiteSpace: "nowrap" }}>Manage. Enrich.</span>
<span style={{ display: "block", whiteSpace: "nowrap" }}>
{"Publish. "}
<span style={{
background: "linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #c084fc 100%)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", backgroundClip: "text",
}}>Everywhere.</span>
</span>
</h1>
<p style={{ marginTop: 8, fontSize: "0.78rem", color: "#6b6886", lineHeight: 1.6, margin: "8px 0 0" }}>
Centralize your product data, assets, and workflows<br />
and deliver consistent experiences across all channels.
</p>
</div>
{/* ── 3D Illustration ── */}
<div className="auth-fade-up2" style={{ flex: 1, minHeight: 0, position: "relative" }}>
<HeroIllustration />
</div>
{/* Feature strip */}
<div className="auth-fade-up3" style={{
flexShrink: 0, display: "flex", gap: 18,
borderTop: "1px solid rgba(124,58,237,0.15)",
paddingTop: 10, paddingBottom: 14,
}}>
{FEATURES.map((f, i) => {
const Icon = f.icon;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 7, whiteSpace: "nowrap" }}>
<div style={{
width: 26, height: 26, borderRadius: 7,
background: "linear-gradient(135deg, #ede9fe, #ddd6fe)",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
<Icon size={13} color="#7c3aed" />
</div>
<div>
<div style={{ fontSize: 10.5, fontWeight: 700, color: "#1f2937", lineHeight: 1 }}>{f.label}</div>
<div style={{ fontSize: 9, color: "#9ca3af", marginTop: 2 }}>{f.desc}</div>
</div>
</div>
);
})}
</div>
</div>
{/* ═══════════════════════════════
RIGHT PANEL (38%) — Login card
═══════════════════════════════ */}
<div className="a-right" style={{
width: "38%", display: "flex", alignItems: "center", justifyContent: "center",
padding: "20px 28px 20px 8px", overflow: "auto",
}}>
<div className="auth-card-in" style={{
background: "white",
borderRadius: 24,
padding: "28px 26px",
width: "100%", maxWidth: 390,
boxShadow: "0 20px 60px rgba(109,40,217,0.10), 0 4px 20px rgba(0,0,0,0.05)",
border: "1px solid rgba(237,233,254,0.8)",
}}>
{children}
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,304 @@
export const HeroIllustration = () => {
return (
<>
<style>{`
@keyframes heroFloat {
0%,100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
@keyframes fi-f1 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-14px)} }
@keyframes fi-f2 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-9px)} }
@keyframes fi-f3 { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-12px)} }
@keyframes fi-glow { 0%,100%{opacity:0.5} 50%{opacity:0.9} }
.fi-laptop { animation: heroFloat 6s ease-in-out infinite; }
.fi-f1 { animation: fi-f1 5s ease-in-out infinite; }
.fi-f2 { animation: fi-f2 7s 0.8s ease-in-out infinite; }
.fi-f3 { animation: fi-f3 6s 1.6s ease-in-out infinite; }
.fi-glow { animation: fi-glow 3.5s ease-in-out infinite; }
`}</style>
{/* viewBox wider (680) so floating elements have room far from the laptop */}
<svg
viewBox="0 0 680 440"
xmlns="http://www.w3.org/2000/svg"
style={{ width: "100%", height: "100%", overflow: "visible" }}
>
<defs>
{/* Platform glow */}
<radialGradient id="fi-pglow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#a78bfa" stopOpacity="0.6"/>
<stop offset="100%" stopColor="#a78bfa" stopOpacity="0"/>
</radialGradient>
<linearGradient id="fi-ring" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#ec4899"/>
<stop offset="100%" stopColor="#8b5cf6"/>
</linearGradient>
{/* Laptop screen */}
<linearGradient id="fi-scr" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#f8f7ff"/>
<stop offset="100%" stopColor="#f1effe"/>
</linearGradient>
{/* Sidebar */}
<linearGradient id="fi-sb" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#1e1b4b"/>
<stop offset="100%" stopColor="#2e2870"/>
</linearGradient>
{/* Add Product btn */}
<linearGradient id="fi-btn" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#7c3aed"/>
<stop offset="100%" stopColor="#a78bfa"/>
</linearGradient>
{/* Keyboard */}
<linearGradient id="fi-kb1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#d1d5db"/>
<stop offset="100%" stopColor="#9ca3af"/>
</linearGradient>
<linearGradient id="fi-kb2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#b0b8c6"/>
<stop offset="100%" stopColor="#6b7280"/>
</linearGradient>
{/* Boxes */}
<linearGradient id="fi-bt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#fde68a"/><stop offset="100%" stopColor="#fbbf24"/></linearGradient>
<linearGradient id="fi-bf" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stopColor="#fbbf24"/><stop offset="100%" stopColor="#d97706"/></linearGradient>
<linearGradient id="fi-bs" x1="0%" y1="0%" x2="100%" y2="0%" ><stop offset="0%" stopColor="#f59e0b"/><stop offset="100%" stopColor="#b45309"/></linearGradient>
{/* 3D Cube */}
<linearGradient id="fi-ct" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#a78bfa"/><stop offset="100%" stopColor="#7c3aed"/></linearGradient>
<linearGradient id="fi-cf" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stopColor="#7c3aed"/><stop offset="100%" stopColor="#5b21b6"/></linearGradient>
<linearGradient id="fi-cs" x1="0%" y1="0%" x2="100%" y2="0%" ><stop offset="0%" stopColor="#6d28d9"/><stop offset="100%" stopColor="#4c1d95"/></linearGradient>
{/* Cart */}
<linearGradient id="fi-cart" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#7c3aed"/><stop offset="100%" stopColor="#6d28d9"/></linearGradient>
{/* Leaf */}
<linearGradient id="fi-leaf" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#4ade80"/><stop offset="100%" stopColor="#16a34a"/></linearGradient>
{/* Asset card bg */}
<linearGradient id="fi-acard" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stopColor="#ede9fe"/><stop offset="100%" stopColor="#c4b5fd"/></linearGradient>
{/* Shadows */}
<filter id="fi-ds" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="12" floodColor="#7c3aed" floodOpacity="0.18"/>
</filter>
<filter id="fi-ws" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="4" stdDeviation="7" floodColor="#000" floodOpacity="0.08"/>
</filter>
{/* Screen clip — clips content to laptop screen rect */}
<clipPath id="fi-sc">
<rect x="180" y="75" width="226" height="244" rx="2"/>
</clipPath>
</defs>
{/* ── Background floating purple dots ── */}
<circle cx="370" cy="34" r="5.5" fill="#c4b5fd" opacity="0.7"/>
<circle cx="540" cy="60" r="4" fill="#a78bfa" opacity="0.5"/>
<circle cx="570" cy="115" r="3" fill="#8b5cf6" opacity="0.4"/>
<circle cx="310" cy="18" r="3.5" fill="#c4b5fd" opacity="0.6"/>
<circle cx="160" cy="42" r="3" fill="#a78bfa" opacity="0.45"/>
{/* ── Platform glow ── */}
<g className="fi-glow">
<ellipse cx="328" cy="402" rx="200" ry="20" fill="url(#fi-pglow)"/>
<ellipse cx="328" cy="402" rx="200" ry="20" fill="none" stroke="url(#fi-ring)" strokeWidth="2.5" opacity="0.8"/>
</g>
{/* ════════════════════════════════
LAPTOP (centered at x≈305)
════════════════════════════════ */}
<g className="fi-laptop" filter="url(#fi-ds)">
{/* Keyboard base trapezoid */}
<path d="M170 338 L214 375 L400 375 L440 338 Z" fill="url(#fi-kb2)"/>
{/* Keyboard top surface */}
<rect x="172" y="330" width="268" height="11" rx="2" fill="url(#fi-kb1)"/>
{/* Trackpad */}
<rect x="278" y="344" width="50" height="24" rx="4" fill="#c0c8d4" opacity="0.6"/>
{/* Screen bezel — dark charcoal like reference */}
<rect x="178" y="62" width="252" height="272" rx="10" fill="#2d2d35"/>
{/* Webcam dot */}
<circle cx="304" cy="73" r="3" fill="#454551"/>
<circle cx="304" cy="73" r="1.5" fill="#1a1a24"/>
{/* Screen area */}
<rect x="180" y="75" width="226" height="244" rx="3" fill="url(#fi-scr)"/>
{/* ── Dashboard content ── */}
<g clipPath="url(#fi-sc)">
{/* Sidebar */}
<rect x="180" y="75" width="52" height="244" fill="url(#fi-sb)"/>
{/* Sidebar logo bar */}
<rect x="186" y="85" width="38" height="8" rx="4" fill="#4338ca" opacity="0.9"/>
{/* Sidebar items */}
<rect x="186" y="100" width="30" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="113" width="36" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
{/* Active item */}
<rect x="184" y="124" width="44" height="14" rx="3" fill="#4338ca" opacity="0.9"/>
<rect x="188" y="129" width="30" height="5" rx="2" fill="white" opacity="0.95"/>
<rect x="186" y="144" width="32" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="157" width="28" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="170" width="34" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="183" width="30" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="196" width="36" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="209" width="28" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="222" width="34" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="235" width="30" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="248" width="32" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="261" width="26" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
<rect x="186" y="274" width="34" height="6" rx="3" fill="#6366f1" opacity="0.4"/>
{/* Content area */}
<rect x="232" y="75" width="174" height="244" fill="white"/>
{/* Top header */}
<rect x="232" y="75" width="174" height="26" fill="#fafafa"/>
<rect x="238" y="83" width="50" height="7" rx="2" fill="#111827"/>
{/* Add Product btn */}
<rect x="368" y="80" width="30" height="13" rx="5" fill="url(#fi-btn)"/>
<rect x="371" y="84" width="24" height="4" rx="2" fill="white" opacity="0.95"/>
{/* Search */}
<rect x="232" y="103" width="174" height="15" fill="white"/>
<rect x="238" y="106" width="85" height="8" rx="3" fill="#f3f4f6" stroke="#e5e7eb" strokeWidth="0.7"/>
<rect x="241" y="109" width="35" height="3" rx="1" fill="#9ca3af"/>
{/* Table header */}
<rect x="232" y="120" width="174" height="11" fill="#f9fafb"/>
<rect x="238" y="123.5" width="20" height="3.5" rx="1" fill="#9ca3af"/>
<rect x="270" y="123.5" width="22" height="3.5" rx="1" fill="#9ca3af"/>
<rect x="306" y="123.5" width="16" height="3.5" rx="1" fill="#9ca3af"/>
<rect x="335" y="123.5" width="18" height="3.5" rx="1" fill="#9ca3af"/>
<rect x="366" y="123.5" width="20" height="3.5" rx="1" fill="#9ca3af"/>
{/* Table rows */}
{[
{ y:131, bg:"white", s:"#10b981", sb:"#d1fae5" },
{ y:147, bg:"#f9fafb", s:"#f59e0b", sb:"#fef3c7" },
{ y:163, bg:"white", s:"#10b981", sb:"#d1fae5" },
{ y:179, bg:"#f9fafb", s:"#10b981", sb:"#d1fae5" },
{ y:195, bg:"white", s:"#ec4899", sb:"#fce7f3" },
{ y:211, bg:"#f9fafb", s:"#10b981", sb:"#d1fae5" },
].map((r, i) => (
<g key={i}>
<rect x="232" y={r.y} width="174" height="14" fill={r.bg}/>
<circle cx="242" cy={r.y+7} r="4" fill="#e5e7eb"/>
<rect x="252" y={r.y+3} width={16+i} height="5" rx="1" fill="#374151"/>
<rect x="274" y={r.y+3} width="20" height="5" rx="1" fill="#6b7280"/>
<rect x="308" y={r.y+2} width="18" height="7" rx="3" fill={r.sb}/>
<rect x="310" y={r.y+4} width="14" height="3" rx="1" fill={r.s}/>
<rect x="337" y={r.y+3} width="18" height="5" rx="1" fill="#374151"/>
<rect x="366" y={r.y+3} width="22" height="5" rx="1" fill="#9ca3af"/>
</g>
))}
</g>
</g>
{/* ════════════════════════════════
FLOATING ELEMENTS (pushed far from laptop center)
════════════════════════════════ */}
{/* ── Analytics card — top RIGHT, far from laptop ── */}
<g className="fi-f1" filter="url(#fi-ws)">
<rect x="488" y="54" width="92" height="76" rx="13" fill="white" opacity="0.96"/>
{/* Bar chart */}
<rect x="498" y="103" width="9" height="14" rx="3" fill="#e9d5ff"/>
<rect x="511" y="94" width="9" height="23" rx="3" fill="#7c3aed"/>
<rect x="524" y="99" width="9" height="18" rx="3" fill="#e9d5ff"/>
<rect x="537" y="86" width="9" height="31" rx="3" fill="#a78bfa"/>
<rect x="550" y="94" width="9" height="23" rx="3" fill="#e9d5ff"/>
<text x="530" y="122" fontSize="8.5" fontWeight="700" fill="#6d28d9" textAnchor="middle">Analytics</text>
</g>
{/* ── Asset Library card — far LEFT ── */}
<g className="fi-f2" filter="url(#fi-ws)">
<rect x="12" y="162" width="96" height="76" rx="13" fill="white" opacity="0.96"/>
<rect x="26" y="172" width="46" height="42" rx="9" fill="url(#fi-acard)"/>
{/* Cloud icon */}
<path d="M42 198 C37 198 34 194 34 190 C34 187 36 185 39 184 C39 180 42 177 46 177 C49 177 51 178 52 181 C53 180 55 180 57 181 C60 183 61 185 60 188 C62 189 64 191 64 193 C64 196 61 198 58 198 Z" fill="#7c3aed" opacity="0.75"/>
<line x1="49" y1="197" x2="49" y2="205" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
<path d="M45 202 L49 197 L53 202" fill="none" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
<text x="60" y="228" fontSize="7.5" fontWeight="700" fill="#5b21b6" textAnchor="middle">Asset Library</text>
</g>
{/* ── Shopping Cart — far left, below asset library ── */}
<g className="fi-f3" filter="url(#fi-ds)">
<rect x="22" y="288" width="56" height="56" rx="14" fill="url(#fi-cart)"/>
<circle cx="40" cy="332" r="3.5" fill="white"/>
<circle cx="58" cy="332" r="3.5" fill="white"/>
<path d="M30 299 L37 299 L44 320 L62 320 L67 305 L43 305" fill="none" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</g>
{/* ── Donut chart — far RIGHT, mid height ── */}
<g className="fi-f2">
<circle cx="564" cy="222" r="35" fill="none" stroke="#e9d5ff" strokeWidth="20"/>
<circle cx="564" cy="222" r="35" fill="none" stroke="#7c3aed" strokeWidth="20"
strokeDasharray="88 132" strokeDashoffset="-8" strokeLinecap="round"/>
<circle cx="564" cy="222" r="35" fill="none" stroke="#f59e0b" strokeWidth="20"
strokeDasharray="38 182" strokeDashoffset="-96" strokeLinecap="round"/>
<circle cx="564" cy="222" r="24" fill="white"/>
</g>
{/* ── Plant — far right, lower ── */}
<g className="fi-f1">
<path d="M560 366 L550 386 L576 386 L566 366 Z" fill="#e5e7eb"/>
<ellipse cx="563" cy="366" rx="11" ry="4" fill="#d1d5db"/>
<ellipse cx="563" cy="366" rx="11" ry="3.5" fill="none" stroke="#9ca3af" strokeWidth="1.5"/>
<line x1="563" y1="366" x2="563" y2="320" stroke="#15803d" strokeWidth="2.5"/>
<line x1="563" y1="345" x2="551" y2="328" stroke="#15803d" strokeWidth="2"/>
<line x1="563" y1="340" x2="577" y2="323" stroke="#15803d" strokeWidth="2"/>
<path d="M563 342 C547 336 540 321 549 313 C555 325 561 333 563 342 Z" fill="url(#fi-leaf)"/>
<path d="M563 334 C579 328 586 313 578 305 C572 317 567 325 563 334 Z" fill="url(#fi-leaf)" opacity="0.9"/>
<path d="M563 353 C545 348 537 336 544 327 C551 337 558 344 563 353 Z" fill="url(#fi-leaf)" opacity="0.85"/>
<path d="M563 346 C581 340 589 328 582 319 C575 329 569 337 563 346 Z" fill="url(#fi-leaf)" opacity="0.9"/>
<path d="M551 328 C537 326 530 316 537 307 C543 317 548 322 551 328 Z" fill="url(#fi-leaf)" opacity="0.8"/>
<path d="M577 323 C591 320 598 310 591 301 C585 311 580 317 577 323 Z" fill="url(#fi-leaf)" opacity="0.8"/>
</g>
{/* ── Cardboard boxes — far lower-left ── */}
<g className="fi-f3">
{/* Big box */}
<path d="M68 305 L114 305 L114 358 L68 358 Z" fill="url(#fi-bf)"/>
<path d="M68 305 L86 288 L132 288 L114 305 Z" fill="url(#fi-bt)"/>
<path d="M114 305 L132 288 L132 342 L114 358 Z" fill="url(#fi-bs)"/>
<line x1="91" y1="305" x2="91" y2="358" stroke="#b45309" strokeWidth="1.2" opacity="0.4"/>
<path d="M68 305 L91 296 M91 296 L114 305" stroke="#b45309" strokeWidth="1" opacity="0.35" fill="none"/>
{/* Medium box */}
<path d="M78 268 L108 268 L108 305 L78 305 Z" fill="url(#fi-bf)"/>
<path d="M78 268 L92 254 L122 254 L108 268 Z" fill="url(#fi-bt)"/>
<path d="M108 268 L122 254 L122 292 L108 305 Z" fill="url(#fi-bs)"/>
{/* Small box */}
<path d="M40 326 L72 326 L72 358 L40 358 Z" fill="url(#fi-bf)"/>
<path d="M40 326 L54 312 L86 312 L72 326 Z" fill="url(#fi-bt)"/>
<path d="M72 326 L86 312 L86 344 L72 358 Z" fill="url(#fi-bs)"/>
</g>
{/* ── Sneaker product card — below laptop center ── */}
<g className="fi-f2" filter="url(#fi-ws)">
<rect x="218" y="352" width="106" height="68" rx="12" fill="white" opacity="0.97"/>
<ellipse cx="264" cy="378" rx="28" ry="11" fill="#ede9fe" opacity="0.65"/>
{/* Shoe */}
<path d="M240 382 C243 369 252 365 261 365 C268 365 275 368 280 374 C285 378 286 383 282 385 C280 387 244 387 240 385 Z" fill="#8b5cf6"/>
<path d="M240 385 L282 385 L282 388 L240 388 Z" fill="#6d28d9"/>
<path d="M247 371 L257 368 L266 371 L263 377 L250 377 Z" fill="white" opacity="0.75"/>
<path d="M264 371 L271 368 L276 372 L272 376 Z" fill="#ede9fe" opacity="0.9"/>
{/* Stars */}
<text x="226" y="410" fontSize="11" fill="#fbbf24"></text>
</g>
{/* ── Purple 3D Cube — far lower-right ── */}
<g className="fi-f1">
<path d="M444 348 L482 330 L520 348 L482 366 Z" fill="url(#fi-ct)"/>
<path d="M444 348 L482 366 L482 408 L444 390 Z" fill="url(#fi-cf)"/>
<path d="M482 366 L520 348 L520 390 L482 408 Z" fill="url(#fi-cs)"/>
{/* Box icon on face */}
<path d="M456 358 L482 347 L508 358 M456 358 L456 370 L482 381 L508 370 L508 358 M482 347 L482 381"
fill="none" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" opacity="0.55"/>
</g>
</svg>
</>
);
};
@@ -1,9 +1,16 @@
// src/authentication/components/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAppSelector } from '../../store';
// Development Mode AuthGuard - Always allows access (No real login yet)
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// In development, we skip real authentication
const { isAuthenticated } = useAppSelector((state) => state.auth);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
+61 -372
View File
@@ -1,392 +1,81 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { authService } from '../services/authService';
import { Package, Mail, ArrowRight, ArrowLeft, KeyRound, Lock, Eye, EyeOff, CheckCircle } from 'lucide-react';
type Step = 'email' | 'otp' | 'reset' | 'success';
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Mail, ArrowRight, KeyRound, ArrowLeft } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
import { authService } from "../services/authService";
export const ForgotPassword = () => {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
// Step 1: Request OTP
const handleRequestOTP = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
setIsLoading(true);
setError("");
try {
const response = await authService.forgotPassword(email);
setMessage(response.message || 'OTP sent to your email');
setStep('otp');
// Use existing auth service mock to trigger forgot password flow
await authService.forgotPassword(email);
// Navigate to reset password and pass the email via state
navigate("/reset-password", { state: { email } });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to send OTP';
setError(errorMessage);
setError(err instanceof Error ? err.message : "Failed to send reset link");
} finally {
setLoading(false);
setIsLoading(false);
}
};
// Step 2: Verify OTP
const handleVerifyOTP = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await authService.verifyOTP(email, otp);
setMessage('OTP verified successfully');
setStep('reset');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Invalid OTP';
setError(errorMessage);
} finally {
setLoading(false);
}
};
// Step 3: Reset Password
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
await authService.resetPassword(email, otp, newPassword, confirmPassword);
setStep('success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Failed to reset password';
setError(errorMessage);
} finally {
setLoading(false);
}
};
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{['email', 'otp', 'reset', 'success'].map((s, i) => (
<div key={s} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step === s || ['email', 'otp', 'reset', 'success'].indexOf(step) > i
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{i + 1}
</div>
{i < 3 && (
<div
className={`w-8 h-0.5 ${
['email', 'otp', 'reset', 'success'].indexOf(step) > i
? 'bg-purple-600'
: 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
);
const renderEmailStep = () => (
<form onSubmit={handleRequestOTP} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Forgot Password?</h2>
<p className="text-gray-600">Enter your email to receive a verification code</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className="w-5 h-5 text-gray-400" />
</div>
<input
type="email"
required
className="block w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? (
<span>Sending...</span>
) : (
<>
<span>Send OTP</span>
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</form>
);
const renderOTPStep = () => (
<form onSubmit={handleVerifyOTP} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Enter Verification Code</h2>
<p className="text-gray-600">We sent a code to <span className="font-medium">{email}</span></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verification Code
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<KeyRound className="w-5 h-5 text-gray-400" />
</div>
<input
type="text"
required
maxLength={6}
className="block w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all text-center text-2xl tracking-widest"
placeholder="000000"
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
{message && (
<div className="p-4 bg-green-50 border border-green-200 rounded-xl text-green-600 text-sm">
{message}
</div>
)}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading || otp.length < 4}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? <span>Verifying...</span> : <span>Verify OTP</span>}
</button>
<button
type="button"
onClick={() => setStep('email')}
className="w-full flex items-center justify-center gap-2 py-3 px-4 border border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition-all"
>
<ArrowLeft className="w-5 h-5" />
<span>Change Email</span>
</button>
</form>
);
const renderResetStep = () => (
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Reset Password</h2>
<p className="text-gray-600">Create a new password for your account</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="w-5 h-5 text-gray-400" />
</div>
<input
type={showPassword ? 'text' : 'password'}
required
minLength={8}
className="block w-full pl-12 pr-12 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Enter new password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center"
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="w-5 h-5 text-gray-400" />
</div>
<input
type={showConfirmPassword ? 'text' : 'password'}
required
minLength={8}
className="block w-full pl-12 pr-12 py-3 border border-gray-300 rounded-xl bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center"
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all disabled:opacity-50"
>
{loading ? <span>Resetting...</span> : <span>Reset Password</span>}
</button>
</form>
);
const renderSuccessStep = () => (
<div className="text-center space-y-6">
<div className="w-20 h-20 mx-auto bg-gradient-to-r from-green-500 to-emerald-500 rounded-full flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-white" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Password Reset Successful!</h2>
<p className="text-gray-600">Your password has been reset successfully. You can now login with your new password.</p>
</div>
<button
onClick={() => navigate('/login')}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-xl font-semibold hover:from-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all"
>
<span>Go to Login</span>
<ArrowRight className="w-5 h-5" />
</button>
</div>
);
return (
<div className="min-h-screen flex">
{/* Left Panel - Decorative */}
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute top-20 left-20 w-72 h-72 bg-purple-500/30 rounded-full blur-3xl"></div>
<div className="absolute bottom-20 right-20 w-96 h-96 bg-pink-500/20 rounded-full blur-3xl"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-blue-500/20 rounded-full blur-3xl"></div>
<AuthLayout>
<div className="flex flex-col items-center text-center mb-6">
<div className="w-12 h-12 bg-purple-50 rounded-full flex items-center justify-center mb-3">
<KeyRound className="w-6 h-6 text-purple-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">Forgot Password?</h2>
<p className="text-gray-500 mt-1 text-xs max-w-xs">
No worries, we'll send you an OTP to help you reset your password.
</p>
</div>
<div className="relative z-10 flex flex-col justify-between p-12 w-full">
<Link to="/" className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Package className="w-7 h-7 text-white" />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="you@example.com"
/>
</div>
<span className="text-2xl font-bold text-white">Maskan<span className="text-purple-400">POS</span></span>
</div>
{error && <div className="text-sm text-red-500">{error}</div>}
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-xl shadow-sm text-sm font-semibold text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Sending OTP..." : "Reset Password"}
<ArrowRight className="w-4 h-4" />
</button>
</form>
<div className="mt-6 text-center text-xs text-gray-600">
<Link to="/login" className="inline-flex items-center font-semibold text-gray-600 hover:text-purple-600 transition-colors">
<ArrowLeft className="w-4 h-4 mr-1.5" />
Back to Sign In
</Link>
<div>
<h1 className="text-4xl font-bold text-white mb-6 leading-tight">
Reset Your
<br />
<span className="bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
Password Securely
</span>
</h1>
<p className="text-gray-400 text-lg max-w-md">
We'll send you a verification code to your registered email address to help you reset your password.
</p>
</div>
<div className="text-gray-500 text-sm">
© 2026 Maskan Inventory. All rights reserved.
</div>
</div>
</div>
{/* Right Panel - Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-gray-50">
<div className="w-full max-w-md">
{/* Mobile logo */}
<div className="lg:hidden flex items-center justify-center gap-3 mb-10">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Package className="w-7 h-7 text-white" />
</div>
<span className="text-2xl font-bold text-gray-900">Maskan<span className="text-purple-600">POS</span></span>
</div>
{renderStepIndicator()}
{step === 'email' && renderEmailStep()}
{step === 'otp' && renderOTPStep()}
{step === 'reset' && renderResetStep()}
{step === 'success' && renderSuccessStep()}
{/* Back to login */}
{step !== 'success' && (
<div className="mt-6 text-center">
<Link to="/login" className="text-sm text-gray-600 hover:text-purple-600 transition-colors">
Back to login
</Link>
</div>
)}
</div>
</div>
</div>
</AuthLayout>
);
};
export default ForgotPassword;
+123 -214
View File
@@ -1,242 +1,151 @@
import { useState } from "react";
import { Navigate, useLocation, Link } from "react-router-dom";
import { useAppSelector, useAppDispatch } from "../../store";
import { authService } from "../services/authService";
import { setAuthLoading, setCredentials } from "../../store/slices/authSlice";
import { Package, Eye, EyeOff, Check } from "lucide-react";
import "./Login.css";
const FEATURE_LIST = [
"Time analytics and role",
"Time analytics and role",
"Time analytics and role",
];
import { Link, useNavigate } from "react-router-dom";
import { useAppDispatch } from "../../store";
import { setCredentials } from "../../store/slices/authSlice";
import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
export const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dispatch = useAppDispatch();
const { isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
const location = useLocation();
// Redirect if already authenticated
if (isAuthenticated) {
const from = location.state?.from?.pathname || "/dashboard";
return <Navigate to={from} replace />;
}
// Show loading state while validating session
if (isLoading && !email && !password) {
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">Authenticating...</h2>
<p className="login-card-subtitle">Please wait while we set up your session</p>
</div>
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div className="spinner" 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>
</div>
</div>
</div>
);
}
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
dispatch(setAuthLoading(true));
setIsLoading(true);
try {
const response = await authService.login({ email, password });
// Mock Login Logic
setTimeout(() => {
dispatch(setCredentials({
user: response.data.user,
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
permissions: response.data.permissions,
accessibleStores: response.data.accessibleStores,
isGlobalAccess: response.data.isGlobalAccess,
user: { id: "1", name: "Admin User", email: email, role: "admin", status: "active" },
accessToken: "mock_token_123",
refreshToken: "mock_refresh_123",
// NOTE: permissions intentionally omitted here so the store's default
// permissions (initialState) are preserved until the real API is wired up.
}));
} catch (err: unknown) {
dispatch(setAuthLoading(false));
const message = err instanceof Error ? err.message : "Login failed. Please try again.";
setError(message);
}
navigate("/dashboard");
setIsLoading(false);
}, 1000);
};
// const handleSSOLogin = () => {
// // Redirect to SaaS platform SSO login
// const saasBaseUrl = import.meta.env.VITE_SAAS_BASE_URL || 'http://localhost:3000';
// const redirectUri = `${window.location.origin}/sso/callback`;
// const ssoUrl = `${saasBaseUrl}/sso/login?client_id=inventory_module&redirect_uri=${encodeURIComponent(redirectUri)}`;
// window.location.href = ssoUrl;
// };
return (
<div className="login-container">
<div className="login-background" />
<div className="login-left-panel">
<div className="login-logo-container">
<div className="login-logo-badge">
<Package className="login-logo-icon" />
</div>
<div className="login-logo-copy">
<span className="login-logo-text">Maskan POS</span>
</div>
<AuthLayout>
<div className="flex flex-col items-center text-center mb-6">
<div className="w-12 h-12 bg-purple-50 rounded-full flex items-center justify-center mb-3">
<Lock className="w-6 h-6 text-purple-600" />
</div>
<div className="login-left-content">
<h1 className="login-main-title">
Manage Your <br />
<span>Inventory</span> With <br />
Confidence
</h1>
<p className="login-description">
Powerful inventory management with integrated POS, real - Time
analytics and role - based access control.
</p>
<ul className="feature-list">
{FEATURE_LIST.map((feature, index) => (
<li key={index} className="feature-item">
<div className="feature-check-icon">
<Check size={16} />
</div>
{feature}
</li>
))}
</ul>
</div>
<p className="login-footer-text">
© 2026 Maskan Inventory All rights reserved.
</p>
<h2 className="text-xl font-bold text-gray-900">Welcome Back</h2>
<p className="text-gray-500 mt-1 text-xs">Sign in to continue to Product Catalog</p>
</div>
<div className="login-right-panel">
<div className="login-card">
<div className="login-card-header">
<h2 className="login-card-title">Welcome Back!</h2>
<p className="login-card-subtitle">
Sign in to your account to continue
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="you@example.com"
/>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">Email Address</label>
<div className="input-wrapper">
<input
type="email"
className="login-input"
placeholder="admin@platform.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<div className="form-group">
<label className="form-label">Password</label>
<div className="input-wrapper">
<input
type={showPassword ? "text" : "password"}
className="login-input"
placeholder="Admin@123"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div className="form-footer">
<label className="checkbox-group">
<input type="checkbox" className="login-checkbox" />
<span>Remember Me</span>
</label>
<Link to="/forgot-password" className="forgot-password-link">
Forgot Password?
</Link>
</div>
{error && <div className="error-message">{error}</div>}
<div className="login-actions">
<Link to="/" className="btn-back">
Back to Home
</Link>
<button type="submit" disabled={isLoading} className="btn-signin">
{isLoading ? "Signing in..." : "Sign in"}
</button>
</div>
{/* <div style={{ margin: '1.5rem 0', textAlign: 'center', position: 'relative' }}>
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: '1px',
backgroundColor: '#e5e7eb'
}} />
<span style={{
position: 'relative',
backgroundColor: 'white',
padding: '0 1rem',
color: '#6b7280',
fontSize: '0.875rem'
}}>OR</span>
</div> */}
{/* <button
<input
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-10 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="Enter your password"
/>
<button
type="button"
onClick={handleSSOLogin}
className="btn-signin"
style={{
width: '100%',
backgroundColor: '#10b981',
borderColor: '#10b981'
}}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
Sign in with SSO
</button> */}
</form>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded cursor-pointer"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-600 cursor-pointer">
Remember me
</label>
</div>
<div className="text-sm">
<Link to="/forgot-password" className="font-medium text-primary hover:text-primary-hover">
Forgot Password?
</Link>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-xl shadow-sm text-sm font-semibold text-white bg-primary hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Signing in..." : "Sign In"}
<ArrowRight className="w-4 h-4" />
</button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-2 bg-white text-gray-500">or continue with</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<button className="w-full inline-flex justify-center py-2 px-4 border border-gray-200 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475656/google-color.svg" alt="Google" />
<span className="ml-2">Google</span>
</button>
<button className="w-full inline-flex justify-center py-2 px-4 border border-gray-200 rounded-xl shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 transition-colors">
<img className="h-4 w-4" src="https://www.svgrepo.com/show/475662/microsoft.svg" alt="Microsoft" />
<span className="ml-2">Microsoft</span>
</button>
</div>
</div>
</div>
<div className="mt-6 text-center text-xs text-gray-600">
Don't have an account?{' '}
<Link to="/register" className="font-semibold text-purple-600 hover:text-purple-500">
Sign Up
</Link>
</div>
</AuthLayout>
);
};
export default Login;
+146
View File
@@ -0,0 +1,146 @@
import { useState, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { Lock, ArrowRight, ShieldCheck, CheckCircle2 } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
import { authService } from "../services/authService";
export const ResetPassword = () => {
const [otp, setOtp] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const email = location.state?.email || "";
useEffect(() => {
if (!email) {
// If accessed without email state, redirect back to forgot password
navigate("/forgot-password", { replace: true });
}
}, [email, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setIsLoading(true);
setError("");
try {
// First verify OTP, then reset password.
// The current mock service does this in two steps or together. Let's just do reset directly.
await authService.resetPassword(email, otp, password, confirmPassword);
setSuccess(true);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to reset password");
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<AuthLayout>
<div className="bg-white p-6 sm:p-8 rounded-3xl shadow-xl shadow-gray-200/50 border border-gray-100 flex flex-col items-center text-center">
<div className="w-12 h-12 bg-green-50 rounded-full flex items-center justify-center mb-4">
<CheckCircle2 className="w-6 h-6 text-green-600" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Password Reset Successfully</h2>
<p className="text-gray-500 mb-6 text-xs max-w-sm">
Your password has been reset successfully. You can now use your new password to sign in.
</p>
<Link
to="/login"
className="w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-xl shadow-sm text-sm font-semibold text-white bg-purple-600 hover:bg-purple-700 transition-colors"
>
Continue to Sign In
</Link>
</div>
</AuthLayout>
);
}
return (
<AuthLayout>
<div className="bg-white p-6 sm:p-8 rounded-3xl shadow-xl shadow-gray-200/50 border border-gray-100">
<div className="flex flex-col items-center text-center mb-6">
<div className="w-12 h-12 bg-purple-50 rounded-full flex items-center justify-center mb-3">
<ShieldCheck className="w-6 h-6 text-purple-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">Enter OTP</h2>
<p className="text-gray-500 mt-1 text-xs max-w-xs">
We sent a code to <span className="font-medium text-gray-900">{email}</span>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">6-Digit OTP</label>
<input
type="text"
required
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
className="block w-full px-4 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-center sm:text-xl tracking-widest transition-all"
placeholder="000000"
/>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">New Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="New strong password"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Confirm Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="Confirm your new password"
/>
</div>
</div>
{error && <div className="text-sm text-red-500 text-center">{error}</div>}
<button
type="submit"
disabled={isLoading || otp.length < 4}
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-xl shadow-sm text-sm font-semibold text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Verifying..." : "Reset Password"}
<ArrowRight className="w-4 h-4" />
</button>
</form>
</div>
</AuthLayout>
);
};
+127
View File
@@ -0,0 +1,127 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Eye, EyeOff, Lock, Mail, ArrowRight, User } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
export const SignUp = () => {
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
});
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
// Mock Register Logic
setTimeout(() => {
setIsLoading(false);
navigate("/login");
}, 1000);
};
return (
<AuthLayout>
<div className="flex flex-col items-center text-center mb-6">
<div className="w-12 h-12 bg-purple-50 rounded-full flex items-center justify-center mb-3">
<User className="w-6 h-6 text-purple-600" />
</div>
<h2 className="text-xl font-bold text-gray-900">Create Account</h2>
<p className="text-gray-500 mt-1 text-xs">Join the Product Catalog platform</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
name="name"
required
value={formData.name}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="John Doe"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="you@example.com"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
required
value={formData.password}
onChange={handleChange}
className="block w-full pl-10 pr-10 py-2 border border-gray-200 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 sm:text-sm transition-all"
placeholder="Create a strong password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center items-center gap-2 py-2 px-4 border border-transparent rounded-xl shadow-sm text-sm font-semibold text-white bg-primary hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Creating account..." : "Create Account"}
<ArrowRight className="w-4 h-4" />
</button>
</form>
<div className="mt-6 text-center text-xs text-gray-600">
Already have an account?{' '}
<Link to="/login" className="font-semibold text-primary hover:text-primary-hover">
Sign In
</Link>
</div>
</AuthLayout>
);
};
+27 -15
View File
@@ -1,17 +1,29 @@
// import { Login } from './pages/Login';
// import { ForgotPassword } from './pages/ForgotPassword';
import { Login } from './pages/Login';
import { SignUp } from './pages/SignUp';
import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword';
// export const authRoutes = [
// {
// path: '/login',
// element: Login,
// public: true
// },
// {
// path: '/forgot-password',
// element: ForgotPassword,
// public: true
// }
// ];
export const authRoutes = [
{
path: '/login',
element: Login,
public: true
},
{
path: '/register',
element: SignUp,
public: true
},
{
path: '/forgot-password',
element: ForgotPassword,
public: true
},
{
path: '/reset-password',
element: ResetPassword,
public: true
}
];
// export { Login, ForgotPassword };
export { Login, SignUp, ForgotPassword, ResetPassword };
+166
View File
@@ -0,0 +1,166 @@
import { useState } from "react";
import { Briefcase, X, CheckCircle2, Layers, Tag, Globe, GitBranch, ArrowRight, Laptop, Headphones, Home, Box } from "lucide-react";
export interface FamilyCardData {
id: string;
name: string;
code: string;
desc: string;
axes: number;
attrGroups: number;
channels: number;
workflow: string;
}
interface FamilyCardProps {
family: FamilyCardData;
selected?: boolean;
onSelect: (id: string) => void;
}
// Helper to get an icon based on name
const getIconForName = (name: string) => {
const lower = name.toLowerCase();
if (lower.includes('laptop') || lower.includes('computer')) return Laptop;
if (lower.includes('headphone') || lower.includes('audio')) return Headphones;
if (lower.includes('home') || lower.includes('smart')) return Home;
return Box;
};
// ─── Full Detail Modal ────────────────────────────────────────────────────────
function FamilyDetailModal({
family,
onClose,
onSelect,
}: {
family: FamilyCardData;
onClose: () => void;
onSelect: (id: string) => void;
}) {
const Icon = getIconForName(family.name);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
{/* Panel */}
<div
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 border-b border-purple-100 px-6 py-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-200 flex items-center justify-center">
<Icon className="w-5 h-5 text-purple-700" />
</div>
<div>
<h2 className="font-bold text-gray-900 text-base leading-tight">{family.name}</h2>
<span className="text-xs font-mono text-purple-700 bg-purple-100 px-2 py-0.5 rounded mt-0.5 inline-block">
{family.code}
</span>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-purple-100 text-gray-400 hover:text-gray-700 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-gray-600 mt-3 leading-relaxed">{family.desc}</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-px bg-gray-100 border-b border-gray-100">
{[
{ icon: GitBranch, label: "Variant Axes", value: family.axes, color: "text-purple-600", bg: "bg-purple-50" },
{ icon: Layers, label: "Attribute Groups", value: family.attrGroups, color: "text-blue-600", bg: "bg-blue-50" },
{ icon: Globe, label: "Channels", value: family.channels, color: "text-emerald-600", bg: "bg-emerald-50" },
{ icon: Tag, label: "Workflow", value: family.workflow, color: "text-orange-600", bg: "bg-orange-50" },
].map(({ icon: StatIcon, label, value, color, bg }) => (
<div key={label} className="bg-white px-5 py-4">
<div className="flex items-center gap-2 mb-1">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${bg}`}>
<StatIcon className={`w-3.5 h-3.5 ${color}`} />
</div>
<span className="text-xs text-gray-500">{label}</span>
</div>
<div className={`font-bold text-sm ${typeof value === "number" ? "text-gray-900 text-lg" : "text-gray-800"}`}>
{value}
</div>
</div>
))}
</div>
{/* Inherited */}
<div className="px-6 py-4 bg-emerald-50/60 border-b border-emerald-100">
<div className="flex items-center gap-1.5 text-xs text-emerald-700 font-medium">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
<span>Inherited: Variant Axes, Attribute Groups, Channels, Workflow</span>
</div>
</div>
{/* Action */}
<div className="px-6 py-4 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-200 rounded-xl text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => { onSelect(family.id); onClose(); }}
className="flex-1 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-sm font-semibold transition-colors flex items-center justify-center gap-2"
>
Use this Family <ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
// ─── Compact Card ─────────────────────────────────────────────────────────────
export function FamilyCard({ family, selected, onSelect }: FamilyCardProps) {
const [showModal, setShowModal] = useState(false);
const Icon = getIconForName(family.name);
return (
<>
<div
onClick={() => setShowModal(true)}
className={`
relative p-4 rounded-xl border cursor-pointer shadow-sm
transition-all duration-150 flex justify-between items-start
${selected
? "bg-purple-50/10 border-purple-500 ring-1 ring-purple-500"
: "bg-white border-slate-200 hover:border-slate-300 hover:shadow-md"
}
`}
>
<div className="flex-1 min-w-0 pr-4">
<div className="text-slate-600 text-[13px] font-medium mb-0.5 truncate">{family.code}</div>
<div className="text-lg font-bold text-slate-900 mb-0.5 truncate">{family.name}</div>
<div className="text-slate-500 text-xs">{family.axes} axes, {family.attrGroups} groups</div>
</div>
<div className="pt-0.5 shrink-0">
<Icon className="w-5 h-5 text-slate-400 stroke-[2]" />
</div>
</div>
{showModal && (
<FamilyDetailModal
family={family}
onClose={() => setShowModal(false)}
onSelect={onSelect}
/>
)}
</>
);
}
+44 -9
View File
@@ -1,11 +1,29 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Bell, ChevronDown, Building2, Globe, LogOut } from "lucide-react";
import { Bell, ChevronDown, Building2, Globe } from "lucide-react";
import { useLanguage, type Language } from "../../contexts/LanguageContext";
import { useHeader } from "../../contexts/HeaderContext";
import { useAppDispatch, useAppSelector } from "../../store";
import { logout } from "../../store/slices/authSlice";
import { useState, useRef, useEffect } from "react";
const PAGE_TITLES: Record<string, { title: string; subtitle?: string }> = {
"/dashboard": { title: "Dashboard", subtitle: "Overview of your PIM platform" },
"/products": { title: "Products", subtitle: "Manage your product catalog" },
"/families": { title: "Product Families", subtitle: "Manage product families" },
"/reports": { title: "Reports & Analytics", subtitle: "Comprehensive insights into your product catalog performance" },
"/users": { title: "Users & Roles", subtitle: "Manage team members, permissions, and access controls" },
"/settings": { title: "Settings", subtitle: "System configurations and preferences" },
};
export function Header() {
const { title, subtitle } = useHeader();
const { language, setLanguage } = useLanguage();
const defaultPage = PAGE_TITLES[location.pathname] || { title: "PIM Platform" };
const displayTitle = title || defaultPage.title;
const displaySubtitle = title ? subtitle : defaultPage.subtitle;
return (
<header className="h-16 w-full flex items-center justify-between px-6 bg-surface border-b border-border sticky top-0 z-30">
<div className="flex flex-col">
@@ -51,15 +69,32 @@ export function Header() {
</button>
</div>
<button className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg hover:bg-surface-muted">
<div className="w-7 h-7 rounded-full bg-linear-to-br from-primary to-primary-hover text-white text-xs font-bold flex items-center justify-center">
JD
</div>
<div className="text-left">
<div className="text-sm font-semibold">John Doe</div>
<div className="text-xs text-muted-foreground -mt-0.5">Admin</div>
</div>
</button>
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg hover:bg-surface-muted"
>
<div className="w-7 h-7 rounded-full bg-linear-to-br from-primary to-primary-hover text-white text-xs font-bold flex items-center justify-center">
{user?.name ? user.name.substring(0, 2).toUpperCase() : "JD"}
</div>
<div className="text-left">
<div className="text-sm font-semibold">{user?.name || "John Doe"}</div>
<div className="text-xs text-muted-foreground -mt-0.5">{user?.role || "Admin"}</div>
</div>
</button>
{showProfileMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-gray-100 py-1 z-50">
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
)}
</div>
</div>
</header>
);
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { Save } from "lucide-react";
import { ArrowLeft, Save, Info, Library, Settings2, ShieldCheck } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { useAssetFamily } from "../hook/useAssetFamily";
@@ -10,14 +10,14 @@ import type { AssetFamilyCreateRequest } from "../types/asset-families.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'active', label: 'Active', desc: 'Family will be available for new assets' },
{ value: 'inactive', label: 'Inactive', desc: 'Family will be hidden from new assets' },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
`w-full border ${error ? 'border-red-500 focus:ring-red-500 bg-red-50/50' : 'border-gray-200 focus:ring-purple-500'} rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-all bg-gray-50/50 hover:bg-gray-50 focus:bg-white`;
const errorClass = 'text-xs text-red-500 mt-1.5 flex items-center gap-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-2';
export default function NewAssetFamily() {
const navigate = useNavigate();
@@ -112,7 +112,44 @@ export default function NewAssetFamily() {
</div>
</div>
</form>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4">
<Button variant="outline" size="md" type="button" onClick={() => navigate("..")} disabled={formik.isSubmitting} className="rounded-xl px-6">
Cancel
</Button>
<Button variant="primary" size="md" type="submit" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white rounded-xl px-6 shadow-sm shadow-purple-200">
{formik.isSubmitting ? 'Saving…' : isEdit ? 'Update Asset Family' : 'Create Asset Family'}
</Button>
</div>
</form>
{/* Sidebar Info */}
<div className="w-full lg:w-80 shrink-0">
<div className="bg-gradient-to-br from-teal-50 via-emerald-50 to-green-50 rounded-2xl p-6 border border-emerald-100 shadow-sm">
<div className="w-10 h-10 rounded-xl bg-white shadow-sm flex items-center justify-center mb-4">
<Library className="w-5 h-5 text-emerald-600" />
</div>
<h3 className="font-semibold text-emerald-900 mb-2">About Asset Families</h3>
<p className="text-sm text-emerald-800/80 leading-relaxed mb-4">
Asset families let you define a hierarchy and link multiple asset types into a single group.
</p>
<div className="space-y-3">
<div className="flex items-start gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
<p className="text-xs text-emerald-800/70 leading-relaxed">Ensure a product has all required assets for a family</p>
</div>
<div className="flex items-start gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
<p className="text-xs text-emerald-800/70 leading-relaxed">Configure unified validation rules</p>
</div>
<div className="flex items-start gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mt-1.5" />
<p className="text-xs text-emerald-800/70 leading-relaxed">Export bundled assets efficiently</p>
</div>
</div>
</div>
</div>
</div>
</PageWrapper>
);
}
+66 -17
View File
@@ -1,23 +1,20 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { Save } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { ArrowLeft, Image as ImageIcon, Video, FileText, Award, Megaphone, HelpCircle, X, Plus, AlertCircle, Settings2, Eye } from "lucide-react";
import { useAssetType } from "../hook/useAssetType";
import { assetTypeSchema } from "../validation/asset-types.schema";
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-purple-500'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
const errorClass = 'text-xs text-red-500 mt-1';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const CATEGORIES = [
{ id: 'image', label: 'Image', icon: ImageIcon, desc: 'jpg, jpeg, png...' },
{ id: 'video', label: 'Video', icon: Video, desc: 'mp4, mov, avi...' },
{ id: 'document', label: 'Document', icon: FileText, desc: 'pdf, docx, doc...' },
{ id: 'certificate', label: 'Certificate', icon: Award, desc: 'pdf, jpg, png' },
{ id: 'marketing', label: 'Marketing', icon: Megaphone, desc: 'jpg, png, svg...' },
{ id: 'other', label: 'Other', icon: HelpCircle, desc: 'pdf, zip, csv...' },
] as const;
export default function NewAssetType() {
const navigate = useNavigate();
@@ -25,6 +22,7 @@ export default function NewAssetType() {
const isEdit = Boolean(id);
const { createItem, updateItem, items, fetchItems } = useAssetType();
const [newFileType, setNewFileType] = useState('');
useEffect(() => {
fetchItems();
@@ -34,7 +32,17 @@ export default function NewAssetType() {
const formik = useFormik({
initialValues: {
name: "",
code: "",
description: "",
status: "active" as "active" | "inactive",
isRequired: false,
category: "" as typeof CATEGORIES[number]['id'] | "",
validation: {
allowedFileTypes: [] as string[],
maxFileSize: 10,
minUploadCount: 0,
maxUploadCount: 1,
}
},
validationSchema: assetTypeSchema,
onSubmit: async (values, { setSubmitting }) => {
@@ -46,7 +54,7 @@ export default function NewAssetType() {
}
navigate("..");
} catch {
// Handled by service/hook
// Error handled in hook
} finally {
setSubmitting(false);
}
@@ -59,13 +67,54 @@ export default function NewAssetType() {
if (match) {
formik.setValues({
name: match.name,
code: match.code || '',
description: match.description || '',
status: match.status as "active" | "inactive",
isRequired: match.isRequired || false,
category: match.category || '',
validation: match.validation || {
allowedFileTypes: [],
maxFileSize: 10,
minUploadCount: 0,
maxUploadCount: 1,
}
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit && !formik.touched.code) {
const generatedCode = e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
formik.setFieldValue('code', generatedCode);
}
};
const handleAddFileType = () => {
if (newFileType.trim() && !formik.values.validation.allowedFileTypes.includes(newFileType.trim().toLowerCase())) {
formik.setFieldValue('validation.allowedFileTypes', [...formik.values.validation.allowedFileTypes, newFileType.trim().toLowerCase()]);
setNewFileType('');
}
};
const removeFileType = (type: string) => {
formik.setFieldValue('validation.allowedFileTypes', formik.values.validation.allowedFileTypes.filter(t => t !== type));
};
const SectionBadge = ({ num, title, subtitle }: { num: number, title: string, subtitle?: string }) => (
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-gray-100">
<div className="w-6 h-6 rounded-full bg-purple-600 text-white flex items-center justify-center text-xs font-bold">
{num}
</div>
<h2 className="text-base font-bold text-gray-900">
{title}
{subtitle && <span className="text-xs font-normal text-gray-400 ml-2">{subtitle}</span>}
</h2>
</div>
);
return (
<PageWrapper>
<Breadcrumb
@@ -109,10 +158,10 @@ export default function NewAssetType() {
))}
</select>
</div>
</div>
</div>
</form>
</PageWrapper>
</div>
</div>
);
}
@@ -1,8 +1,21 @@
export interface AssetTypeValidation {
allowedFileTypes: string[];
maxFileSize: number;
minUploadCount: number;
maxUploadCount: number;
}
export interface AssetType {
id: string;
name: string;
code: string;
description?: string;
status: 'active' | 'inactive';
isRequired: boolean;
category: 'image' | 'video' | 'document' | 'certificate' | 'marketing' | 'other' | '';
validation: AssetTypeValidation;
createdAt: string;
}
export type AssetTypeCreateRequest = Omit<AssetType, 'id' | 'createdAt'>;
export type AssetTypeUpdateRequest = Partial<AssetTypeCreateRequest>;
@@ -1,6 +1,16 @@
import * as Yup from 'yup';
export const assetTypeSchema = Yup.object().shape({
name: Yup.string().required('Asset Type name is required'),
name: Yup.string().required('Asset Type Name is required'),
code: Yup.string().required('Asset Code is required'),
description: Yup.string(),
status: Yup.string().oneOf(['active', 'inactive']),
isRequired: Yup.boolean(),
category: Yup.string().required('Asset Category is required'),
validation: Yup.object().shape({
allowedFileTypes: Yup.array().of(Yup.string()),
maxFileSize: Yup.number().min(1, 'Min size is 1MB').required('Max file size is required'),
minUploadCount: Yup.number().min(0, 'Min count cannot be negative').required(),
maxUploadCount: Yup.number().min(1, 'Max count must be at least 1').required(),
}),
});
+90 -43
View File
@@ -1,4 +1,6 @@
import { Plus, Edit2, Trash2, Eye, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Globe, Smartphone, Monitor } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Edit2, Trash2, RefreshCw, Radio, CheckCircle, Layers, TrendingUp, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Smartphone, Globe, Monitor } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
@@ -16,24 +18,46 @@ const MOCK_CHANNELS = [
{ id: "6", name: "SAP ERP System", desc: "SAP S/4HANA enterprise resource planning integration", code: "sap_erp", type: "ERP", status: "active", families: 28, products: "11,200", updated: "2025-03-05", author: "Tech Team", icon: Monitor, typeColor: "text-red-600", typeBg: "bg-red-50" },
];
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useChannel } from "../hook/useChannel";
import type { Channel } from "../types/channels.types";
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" },
marketplace: { label: "Marketplace", icon: ShoppingBag, typeColor: "text-orange-600", typeBg: "bg-orange-50" },
erp: { label: "ERP", icon: Server, typeColor: "text-red-600", typeBg: "bg-red-50" },
wms: { label: "WMS", icon: Warehouse, typeColor: "text-yellow-600", typeBg: "bg-yellow-50" },
pos: { label: "POS", icon: Store, typeColor: "text-green-600", typeBg: "bg-green-50" },
b2b_portal: { label: "B2B Portal", icon: Globe, typeColor: "text-cyan-600", typeBg: "bg-cyan-50" },
mobile_app: { label: "Mobile App", icon: Smartphone, typeColor: "text-purple-600", typeBg: "bg-purple-50" },
website: { label: "Website", icon: Monitor, typeColor: "text-indigo-600", typeBg: "bg-indigo-50" },
};
export default function ChannelList() {
usePageHeader("Channel Master", "Manage publication destinations for product information");
const [searchQuery, setSearchQuery] = useState("");
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useChannel();
useEffect(() => {
fetchItems();
}, [fetchItems]);
const columns = [
{
key: "name",
label: "Channel Name",
sortable: true,
render: (_: any, row: any) => {
const Icon = row.icon;
render: (_: any, row: Channel) => {
const meta = CHANNEL_TYPES_META[row.channelType || ""] || CHANNEL_TYPES_META.website;
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${row.typeBg}`}>
<Icon className={`w-4 h-4 ${row.typeColor}`} />
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${meta.typeBg}`}>
<Icon className={`w-4 h-4 ${meta.typeColor}`} />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.description || 'No description provided'}</div>
</div>
</div>
);
@@ -45,14 +69,15 @@ export default function ChannelList() {
render: (val: string) => <span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{val}</span>,
},
{
key: "type",
key: "channelType",
label: "Type",
render: (val: string, row: any) => {
const Icon = row.icon;
render: (val: string) => {
const meta = CHANNEL_TYPES_META[val] || CHANNEL_TYPES_META.website;
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 ${row.typeBg} ${row.typeColor}`}>
<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}`}>
<Icon className="w-3 h-3" />
{val}
{meta.label}
</div>
);
},
@@ -62,39 +87,52 @@ export default function ChannelList() {
label: "Status",
render: (val: any) => <StatusBadge status={val} />,
},
{ key: "families", label: "Families" },
{ key: "products", label: "Products" },
{ key: "families", label: "Families", render: (val: any) => val || 0 },
{ key: "products", label: "Products", render: (val: any) => val ? val.toLocaleString() : 0 },
{
key: "updated",
key: "createdAt",
label: "Updated",
render: (_: any, row: any) => (
render: (val: any, row: Channel) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.updated}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author}</div>
<div className="text-gray-900 font-medium text-sm">{new Date(val).toLocaleDateString()}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.author || 'Admin'}</div>
</div>
),
},
];
const actions = () => (
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this channel?")) {
try {
await deleteItem(id);
} catch {
// handled
}
}
};
const actions = (row: Channel) => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
<button onClick={() => navigate(`${row.id}/edit`)} className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(row.id)} className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
);
const filteredItems = items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) || (item.code && item.code.toLowerCase().includes(searchQuery.toLowerCase())));
const activeCount = items.filter(i => i.status === 'active').length;
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Channel Master" }]}
actions={
<>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50">
<RefreshCw className="w-4 h-4 mr-2" />
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white">
<Button onClick={() => navigate("new")} className="bg-primary hover:bg-primary-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Channel
</Button>
@@ -135,26 +173,35 @@ export default function ChannelList() {
</KPIGrid>
{/* Main Content Area */}
<div className="mb-8">
<DataTable
columns={columns}
data={MOCK_CHANNELS}
actions={actions}
searchPlaceholder="Search channels..."
toolbarLeft={
<div className="flex gap-2">
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Statuses</option>
<option>Active</option>
<option>Inactive</option>
</select>
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-gray-700 bg-white">
<option>All Types</option>
<option>Ecommerce</option>
<option>Marketplace</option>
</select>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-gray-200 flex justify-between items-center bg-white">
<div className="flex gap-4 items-center">
<div className="w-64">
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search channels..." />
</div>
}
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-gray-700 bg-white">
<option>All Statuses</option>
<option>Active</option>
<option>Inactive</option>
</select>
<select className="h-9 px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary text-gray-700 bg-white">
<option>All Types</option>
<option>Ecommerce</option>
<option>Marketplace</option>
</select>
</div>
<div className="text-sm text-gray-500">
{filteredItems.length} of {items.length} channels
</div>
</div>
{/* Table */}
<Table
columns={columns}
data={filteredItems}
actions={actions}
variant="flat"
/>
</div>
</PageWrapper>
+123 -71
View File
@@ -1,26 +1,27 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
ShoppingCart, Store, Server, Warehouse, CreditCard,
Building2, Smartphone, Globe, Upload, Zap
ShoppingCart, ShoppingBag, Server, Warehouse, Store,
Globe, Smartphone, Monitor, Upload, Zap
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useChannel } from "../hook/useChannel";
import { channelsService } from "../services/channels.service";
import type { ChannelCreateRequest } from "../types/channels.types";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const CHANNEL_TYPES = [
{ id: "ecommerce", label: "Ecommerce", icon: ShoppingCart, color: "text-blue-500", bg: "bg-blue-50" },
{ id: "marketplace", label: "Marketplace", icon: Store, color: "text-orange-500", bg: "bg-orange-50" },
{ id: "marketplace", label: "Marketplace", icon: ShoppingBag, color: "text-orange-500", bg: "bg-orange-50" },
{ id: "erp", label: "ERP", icon: Server, color: "text-red-500", bg: "bg-red-50" },
{ id: "wms", label: "WMS", icon: Warehouse, color: "text-yellow-600", bg: "bg-yellow-50" },
{ id: "pos", label: "POS", icon: CreditCard, color: "text-green-500", bg: "bg-green-50" },
{ id: "b2b_portal", label: "B2B Portal", icon: Building2, color: "text-cyan-500", bg: "bg-cyan-50" },
{ id: "pos", label: "POS", icon: Store, color: "text-green-500", bg: "bg-green-50" },
{ id: "b2b_portal", label: "B2B Portal", icon: Globe, color: "text-cyan-500", bg: "bg-cyan-50" },
{ id: "mobile_app", label: "Mobile App", icon: Smartphone, color: "text-purple-500", bg: "bg-purple-50" },
{ id: "website", label: "Website", icon: Globe, color: "text-indigo-500", bg: "bg-indigo-50" },
{ id: "website", label: "Website", icon: Monitor, color: "text-indigo-500", bg: "bg-indigo-50" },
];
const channelSchema = Yup.object({
@@ -65,6 +66,24 @@ export default function NewChannel() {
},
});
useEffect(() => {
if (isEdit && id) {
channelsService.getById(id).then((channel) => {
if (channel) {
formik.setValues({
name: channel.name,
code: channel.code || "",
description: channel.description || "",
status: channel.status,
channelType: channel.channelType || "",
allowPublishing: channel.allowPublishing ?? true,
});
setSelectedType(channel.channelType || null);
}
});
}
}, [id, isEdit]);
const inputClass = (error?: boolean) =>
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-gray-200 focus:ring-purple-500"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
@@ -94,13 +113,13 @@ export default function NewChannel() {
/>
<form onSubmit={formik.handleSubmit} className="max-w-2xl mx-auto w-full space-y-6">
{/* 1. Basic Information */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">1</div>
<h2 className="font-semibold text-gray-900">Basic Information</h2>
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
</div>
<div className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-5 pl-9">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Channel Name *</label>
<input
@@ -160,14 +179,14 @@ export default function NewChannel() {
</div>
{/* 2. Channel Type */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">2</div>
<h2 className="font-semibold text-gray-900">Channel Type</h2>
<h2 className="text-lg font-semibold text-gray-900">Channel Type</h2>
</div>
<div className="p-6">
<div className="pl-9">
<p className="text-sm text-gray-500 mb-4">Select the type of destination this channel represents.</p>
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-4 gap-4">
{CHANNEL_TYPES.map((type) => {
const Icon = type.icon;
const isSelected = selectedType === type.id;
@@ -179,7 +198,7 @@ export default function NewChannel() {
setSelectedType(type.id);
formik.setFieldValue("channelType", type.id);
}}
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all gap-2 ${
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all gap-3 ${
isSelected
? "border-purple-500 bg-purple-50 shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
@@ -188,7 +207,7 @@ export default function NewChannel() {
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${type.bg}`}>
<Icon className={`w-5 h-5 ${type.color}`} />
</div>
<span className={`text-xs font-medium ${isSelected ? "text-purple-700" : "text-gray-700"}`}>
<span className={`text-sm font-medium ${isSelected ? "text-purple-700" : "text-gray-700"}`}>
{type.label}
</span>
</button>
@@ -202,12 +221,12 @@ export default function NewChannel() {
</div>
{/* 3. Channel Icon */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-gray-400 text-white rounded-full flex items-center justify-center text-xs font-bold">3</div>
<h2 className="font-semibold text-gray-900">Channel Icon <span className="text-xs text-gray-400 font-normal ml-1">Optional</span></h2>
<h2 className="text-lg font-semibold text-gray-900">Channel Icon <span className="text-sm text-gray-400 font-normal ml-2">Optional</span></h2>
</div>
<div className="p-6">
<div className="pl-9">
<div className="flex items-start gap-8">
<div>
<p className="text-xs text-gray-500 font-medium mb-2">Upload Icon</p>
@@ -229,30 +248,30 @@ export default function NewChannel() {
</div>
{/* 4. Availability */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">4</div>
<h2 className="font-semibold text-gray-900">Availability</h2>
<h2 className="text-lg font-semibold text-gray-900">Availability</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-50 rounded-lg flex items-center justify-center">
<Zap className="w-4 h-4 text-purple-600" />
<div className="pl-9">
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-xl bg-white">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">Allow Product Publishing</p>
<p className="text-xs text-gray-500">When enabled, products can be published and syndicated to this channel</p>
<p className="font-medium text-gray-900">Allow Product Publishing</p>
<p className="text-sm text-gray-500">When enabled, products can be published and syndicated to this channel</p>
</div>
</div>
<button
type="button"
onClick={() => formik.setFieldValue("allowPublishing", !formik.values.allowPublishing)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
formik.values.allowPublishing ? "bg-purple-600" : "bg-gray-200"
}`}
>
<span className={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
<span className={`inline-block h-6 w-6 rounded-full bg-white shadow transform transition-transform duration-200 ${
formik.values.allowPublishing ? "translate-x-5" : "translate-x-0"
}`} />
</button>
@@ -261,53 +280,86 @@ export default function NewChannel() {
</div>
{/* 5. Summary */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-6 h-6 bg-purple-600 text-white rounded-full flex items-center justify-center text-xs font-bold">5</div>
<h2 className="font-semibold text-gray-900">Summary</h2>
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
<div>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-2">Channel Information</p>
<div className="space-y-1.5">
<div className="flex justify-between">
<span className="text-gray-500">Name</span>
<span className="font-medium text-gray-900">{formik.values.name || "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Code</span>
<code className="font-mono text-xs text-gray-700 bg-gray-100 px-1.5 py-0.5 rounded">{formik.values.code || "—"}</code>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className="capitalize font-medium text-gray-900">{formik.values.status}</span>
<div className="pl-9 grid grid-cols-2 gap-4">
{/* Box 1 */}
<div className="rounded-xl border border-purple-100 bg-purple-50/50 p-4">
<p className="text-[10px] font-bold text-purple-800 uppercase tracking-wider mb-4">Channel Information</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-500">Name</span>
<span className="font-medium text-gray-900">{formik.values.name || "—"}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Code</span>
<span className="font-medium text-gray-900 px-2 py-0.5 bg-white border border-gray-200 rounded text-xs">{formik.values.code || "—"}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-500">Status</span>
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${formik.values.status === 'active' ? 'bg-emerald-500' : 'bg-gray-400'}`} />
<span className={`font-medium capitalize ${formik.values.status === 'active' ? 'text-emerald-700' : 'text-gray-600'}`}>{formik.values.status}</span>
</div>
</div>
</div>
<div>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-2">Type</p>
<div className="space-y-1.5">
<div className="flex justify-between">
<span className="text-gray-500">Channel Type</span>
<span className="font-medium text-gray-900">
{selectedType ? CHANNEL_TYPES.find(t => t.id === selectedType)?.label : "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Publishing</span>
<span className={`font-medium ${formik.values.allowPublishing ? "text-emerald-600" : "text-gray-400"}`}>
{formik.values.allowPublishing ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</div>
{/* Box 2 */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-4">Type</p>
{selectedType ? (
<div className="flex items-center gap-3">
{(() => {
const t = CHANNEL_TYPES.find(c => c.id === selectedType);
if (!t) return null;
const Icon = t.icon;
return (
<>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${t.bg}`}>
<Icon className={`w-4 h-4 ${t.color}`} />
</div>
<span className="font-medium text-gray-900">{t.label}</span>
</>
);
})()}
</div>
) : (
<span className="text-sm text-gray-500">Not selected</span>
)}
</div>
{/* Box 3 */}
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-3">Assigned Families</p>
<div className="flex items-center gap-2 mb-1">
<div className="w-4 h-4 rounded-full bg-blue-100 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
</div>
<p className="text-sm font-medium text-gray-900">No families assigned yet</p>
</div>
<p className="text-xs text-gray-500 ml-6">Families are assigned from the Product Family module</p>
</div>
{/* Box 4 */}
<div className="rounded-xl border border-gray-200 bg-white p-4">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-3">Assigned Products</p>
<div className="flex items-center gap-2 mb-1">
<div className="w-4 h-4 rounded-full bg-orange-100 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
</div>
<p className="text-sm font-medium text-gray-900">No products published yet</p>
</div>
<p className="text-xs text-gray-500 ml-6">{formik.values.allowPublishing ? "Publishing enabled" : "Publishing disabled"}</p>
</div>
</div>
</div>
{/* Bottom Actions */}
<div className="flex justify-end gap-3 py-4">
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200 mt-8">
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
<Button type="submit" loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">
<Zap className="w-4 h-4 mr-2" />
@@ -2,9 +2,19 @@ import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../typ
const STORAGE_KEY = 'pim_channels';
const INITIAL_DATA: Channel[] = [
{ id: "1", name: "Shopify Storefront", description: "Primary ecommerce storefront powered by Shopify", code: "shopify_main", channelType: "ecommerce", status: "active", families: 24, products: 8942, createdAt: "2025-03-15", author: "Sarah Chen" },
{ id: "2", name: "Amazon Marketplace", description: "Amazon.com US marketplace listing channel", code: "amazon_us", channelType: "marketplace", status: "active", families: 18, products: 6241, createdAt: "2025-03-12", author: "Michael Torres" },
{ id: "3", name: "WooCommerce Store", description: "European WooCommerce store for EU market", code: "woocommerce_eu", channelType: "ecommerce", status: "active", families: 15, products: 4312, createdAt: "2025-02-28", author: "Emma Wilson" },
{ id: "4", name: "Company Website", description: "Corporate marketing website product catalog", code: "website_main", channelType: "website", status: "active", families: 30, products: 12840, createdAt: "2025-03-14", author: "Admin User" },
];
const getStored = (): Channel[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
return INITIAL_DATA;
}
return JSON.parse(stored);
};
@@ -1,8 +1,16 @@
export interface Channel {
id: string;
name: string;
code?: string;
description?: string;
channelType?: string;
allowPublishing?: boolean;
status: 'active' | 'inactive';
createdAt: string;
updatedAt?: string;
author?: string;
families?: number;
products?: number;
}
export type ChannelCreateRequest = Omit<Channel, 'id' | 'createdAt'>;
export type ChannelUpdateRequest = Partial<ChannelCreateRequest>;
@@ -1,99 +1,175 @@
import { useState } from "react";
import { Plus, Edit2, Eye, MoreHorizontal, Activity, FileText, Download } from "lucide-react";
import { ShoppingBag, ShoppingCart, Smartphone } from "lucide-react";
import { useState, useEffect } from "react";
import { Plus, Edit2, Trash2, RefreshCw, AlertCircle, Package, Clock, Plug, CheckCircle, ShoppingCart, ShoppingBag, Server, Warehouse, Store, Globe, Smartphone, Monitor, Code2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { IntegrationStatsCards } from "../components/IntegrationStatsCards";
// Mock Data
const MOCK_INTEGRATIONS = [
{ id: "1", name: "Amazon India", desc: "Primary Amazon India marketplace...", channel: "Amazon India", type: "Marketplace", env: "Production", status: "Connected", lastSync: "2025-06-09 14:32", syncErrors: 3, published: "4,240", author: "Sarah Chen", createdAt: "2024-01-10", icon: ShoppingBag, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "2", name: "Amazon UAE", desc: "Amazon UAE marketplace for Middle...", channel: "Amazon UAE", type: "Marketplace", env: "Production", status: "Connected", lastSync: "2025-06-09 13:15", syncErrors: 0, published: "1,840", author: "Michael Torres", createdAt: "2024-02-14", icon: ShoppingBag, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "3", name: "Shopify Main Store", desc: "Main Shopify storefront with...", channel: "Shopify Storefront", type: "E-Commerce", env: "Production", status: "Connected", lastSync: "2025-06-09 14:00", syncErrors: 12, published: "8,942", author: "Emma Wilson", createdAt: "2024-03-01", icon: ShoppingCart, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
{ id: "4", name: "Retail POS Network", desc: "Point-of-sale retail network product...", channel: "Retail POS", type: "POS", env: "Production", status: "Connected", lastSync: "2025-06-09 12:00", syncErrors: 0, published: "3,240", author: "David Park", createdAt: "2024-04-15", icon: Smartphone, iconColor: "text-purple-600", iconBg: "bg-purple-50" },
];
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
import type { Integration } from "../types/integrations.types";
const INTEGRATION_META: Record<string, { label: string, icon: any, color: string, bg: string }> = {
ecommerce: { label: "E-Commerce", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-50" },
marketplace: { label: "Marketplace", icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-50" },
erp: { label: "ERP", icon: Server, color: "text-red-600", bg: "bg-red-50" },
wms: { label: "WMS", icon: Warehouse, color: "text-yellow-600", bg: "bg-yellow-50" },
pos: { label: "POS", icon: Store, color: "text-green-600", bg: "bg-green-50" },
b2b_portal: { label: "B2B Portal", icon: Globe, color: "text-cyan-600", bg: "bg-cyan-50" },
mobile_app: { label: "Mobile App", icon: Smartphone, color: "text-purple-600", bg: "bg-purple-50" },
website: { label: "Website", icon: Monitor, color: "text-indigo-600", bg: "bg-indigo-50" },
custom_api: { label: "Custom API", icon: Code2, color: "text-teal-600", bg: "bg-teal-50" },
};
export default function IntegrationList() {
const [activeTab, setActiveTab] = useState("Connections");
const [statusFilter, setStatusFilter] = useState("All Status");
const [typeFilter, setTypeFilter] = useState("All Types");
const navigate = useNavigate();
const { items, fetchItems, loading, deleteItem } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
useEffect(() => {
fetchItems();
fetchChannels();
}, [fetchItems, fetchChannels]);
const handleDelete = async (id: string) => {
if (confirm("Are you sure you want to delete this integration?")) {
try {
await deleteItem(id);
} catch {
// handled
}
}
};
const columns = [
{
key: "name",
label: "Integration Name",
sortable: true,
render: (_: any, row: any) => {
const Icon = row.icon;
render: (_: any, row: Integration) => {
const meta = INTEGRATION_META[row.integrationType] || INTEGRATION_META.custom_api;
const Icon = meta.icon;
return (
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${row.iconBg}`}>
<Icon className={`w-4 h-4 ${row.iconColor}`} />
<div className={`w-8 h-8 rounded flex items-center justify-center shrink-0 ${meta.bg}`}>
<Icon className={`w-4 h-4 ${meta.color}`} />
</div>
<div>
<div className="font-medium text-gray-900">{row.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.desc}</div>
<div className="text-xs text-gray-500 mt-0.5">{row.description || "No description provided"}</div>
</div>
</div>
);
},
},
{ key: "channel", label: "Channel" },
{ key: "type", label: "Type" },
{
key: "env",
key: "channel",
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>;
}
},
{
key: "integrationType",
label: "Type",
render: (val: string) => {
const meta = INTEGRATION_META[val] || INTEGRATION_META.custom_api;
return (
<span className="text-xs font-medium text-gray-600 capitalize">
{meta.label}
</span>
);
}
},
{
key: "environment",
label: "Environment",
render: (val: string) => <span className="text-blue-600 text-sm">{val}</span>,
render: (val: string) => (
<span className="text-blue-600 text-sm font-medium capitalize">{val}</span>
),
},
{
key: "status",
label: "Connection Status",
render: (val: string) => <StatusBadge status={val === "Connected" ? "success" : "neutral"} label={val} />,
render: (val: string) => {
const isSuccess = val === "Connected" || val === "active" || val === "Connected";
const isWarning = val === "Pending" || val === "pending";
return (
<StatusBadge
status={isSuccess ? "success" : isWarning ? "warning" : "neutral"}
label={val === "active" ? "Connected" : val === "pending" ? "Pending" : val}
/>
);
},
},
{
key: "lastSync",
label: "Last Sync",
render: (_: any, row: any) => (
render: (_: any, row: Integration) => (
<div>
<div className="text-gray-900 font-medium text-sm">{row.lastSync}</div>
{row.syncErrors > 0 && (
<div className="text-gray-900 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>
)}
) : null}
</div>
),
},
{ key: "published", label: "Published" },
{ key: "published", label: "Published", render: (val: any) => val || 0 },
{
key: "createdAt",
label: "Created By",
render: (_: any, row: any) => (
render: (_: any, row: Integration) => (
<div>
<div className="text-gray-900 text-sm">{row.author}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.createdAt}</div>
<div className="text-gray-900 text-sm font-medium">{row.author || "Admin"}</div>
<div className="text-gray-500 text-xs mt-0.5">{row.createdAt ? new Date(row.createdAt).toLocaleDateString() : "—"}</div>
</div>
),
},
];
const actions = () => (
const actions = (row: Integration) => (
<div className="flex items-center justify-end gap-1 text-gray-400">
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Eye className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button className="p-1.5 hover:bg-white hover:text-gray-900 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><MoreHorizontal className="w-4 h-4" /></button>
<button onClick={() => navigate(`${row.id}/edit`)} title="Edit" className="p-1.5 hover:bg-white hover:text-purple-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(row.id)} title="Delete" className="p-1.5 hover:bg-white hover:text-red-600 rounded shadow-sm hover:ring-1 ring-gray-200 transition-all"><Trash2 className="w-4 h-4" /></button>
</div>
);
const filteredItems = items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.description && item.description.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesStatus = statusFilter === "All Status" ||
(statusFilter === "Connected" && (item.status === "Connected" || item.status === "active")) ||
(statusFilter === "Pending" && (item.status === "Pending" || item.status === "pending")) ||
(statusFilter === "Disconnected" && (item.status === "Disconnected" || item.status === "inactive"));
const matchesType = typeFilter === "All Types" ||
(typeFilter.toLowerCase() === item.integrationType.toLowerCase());
return matchesSearch && matchesStatus && matchesType;
});
const connectedCount = items.filter(i => i.status === "Connected" || i.status === "active").length;
const failedCount = items.filter(i => i.syncErrors && i.syncErrors > 0).length;
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Integration Hub" }]}
actions={
<>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50"><Activity className="w-4 h-4 mr-2" />Sync Dashboard</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50"><FileText className="w-4 h-4 mr-2" />View Logs</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50"><Download className="w-4 h-4 mr-2" />Export Config</Button>
<Button className="bg-purple-600 hover:bg-purple-700 text-white"><Plus className="w-4 h-4 mr-2" />New Integration</Button>
<Button variant="outline" className="bg-white border-gray-200 text-gray-700 hover:bg-gray-50" onClick={fetchItems}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => navigate("new")} className="bg-purple-600 hover:bg-purple-700 text-white"><Plus className="w-4 h-4 mr-2" />New Integration</Button>
</>
}
/>
@@ -107,10 +183,10 @@ export default function IntegrationList() {
{/* Tabs */}
<div className="flex border-b border-gray-100 px-2 pt-2">
{[
{ id: 'Connections', count: 6 },
{ id: 'Connections', count: items.length },
{ id: 'Publishing Rules', count: 5 },
{ id: 'Sync Jobs', count: 6 },
{ id: 'Error Center', count: 3 },
{ id: 'Error Center', count: failedCount },
{ id: 'Audit & Logs', count: null }
].map(tab => (
<button
@@ -157,6 +233,14 @@ export default function IntegrationList() {
}
/>
</div>
{/* Table */}
<Table
columns={columns}
data={filteredItems}
actions={actions}
variant="flat"
/>
</div>
</PageWrapper>
);
+568 -101
View File
@@ -1,24 +1,30 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import {
Zap, ShoppingCart, Server, Warehouse,
CreditCard, Code2, Store, Eye, EyeOff, Settings2,
Zap, ShoppingCart, ShoppingBag, Server, Warehouse,
Store, Globe, Smartphone, Monitor, Code2, Eye, EyeOff, Settings2,
Calendar, Clock, Wifi, ChevronDown, Info
} from "lucide-react";
import { Button } from "../../../components/customs/Button";
import { useIntegration } from "../hook/useIntegration";
import { useChannel } from "../../channels/hook/useChannel";
import { integrationsService } from "../services/integrations.service";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const TABS = ["General Information", "Connection Configuration", "Synchronisation Settings", "Advanced Configuration"];
const INTEGRATION_TYPES = [
{ id: "marketplace", label: "Marketplace", sub: "Amazon, eBay, Flipkart, Noon", icon: Store },
{ 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: CreditCard },
{ 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 },
];
@@ -29,15 +35,6 @@ const integrationSchema = Yup.object({
environment: Yup.string().required(),
status: Yup.string().required(),
description: Yup.string(),
storeUrl: Yup.string().url("Must be a valid URL").when("integrationType", {
is: "ecommerce",
then: (s) => s.required("Store URL is required"),
otherwise: (s) => s,
}),
accessToken: Yup.string(),
apiVersion: Yup.string(),
webhookSecret: Yup.string(),
shopIdentifier: Yup.string(),
syncDirection: Yup.string().required(),
syncFrequency: Yup.string().required(),
autoRetry: Yup.boolean(),
@@ -46,8 +43,18 @@ const integrationSchema = Yup.object({
export default function NewIntegration() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const [activeTab, setActiveTab] = useState(0);
const [showToken, setShowToken] = useState(false);
const [loadingItem, setLoadingItem] = useState(false);
const { createItem, updateItem } = useIntegration();
const { items: channels, fetchItems: fetchChannels } = useChannel();
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
const formik = useFormik({
initialValues: {
@@ -55,7 +62,7 @@ export default function NewIntegration() {
channel: "",
integrationType: "ecommerce",
environment: "production",
status: "pending",
status: "Connected",
description: "",
storeUrl: "",
accessToken: "",
@@ -66,11 +73,45 @@ export default function NewIntegration() {
syncFrequency: "hourly",
autoRetry: true,
retryAttempts: 3,
// Marketplace specific
sellerId: "",
marketplaceId: "",
awsAccessKeyId: "",
awsSecretKey: "",
clientId: "",
clientSecret: "",
// ERP/WMS specific
authMethod: "bearer",
authToken: "",
username: "",
password: "",
// POS specific
posTerminalId: "",
posStoreCode: "",
posApiKey: "",
posApiSecret: "",
// Mobile App specific
appId: "",
bundleIdentifier: "",
gatewayUrl: "",
// Custom API specific
customApiUrl: "",
customApiHeaderKey: "",
customApiHeaderValue: "",
},
validationSchema: integrationSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
console.log("Integration created:", values);
if (isEdit && id) {
await updateItem(id, values as any);
} else {
await createItem(values as any);
}
navigate("..");
} catch {
// handled
@@ -80,6 +121,67 @@ export default function NewIntegration() {
},
});
useEffect(() => {
if (isEdit && id) {
setLoadingItem(true);
integrationsService.getById(id).then((item) => {
if (item) {
formik.setValues({
name: item.name,
channel: item.channel,
integrationType: item.integrationType,
environment: item.environment,
status: item.status,
description: item.description || "",
storeUrl: item.storeUrl || "",
accessToken: item.accessToken || "",
apiVersion: item.apiVersion || "2025-01",
webhookSecret: item.webhookSecret || "",
shopIdentifier: item.shopIdentifier || "",
syncDirection: item.syncDirection,
syncFrequency: item.syncFrequency,
autoRetry: item.autoRetry,
retryAttempts: item.retryAttempts,
// Other credentials
sellerId: item.sellerId || "",
marketplaceId: item.marketplaceId || "",
awsAccessKeyId: item.awsAccessKeyId || "",
awsSecretKey: item.awsSecretKey || "",
clientId: item.clientId || "",
clientSecret: item.clientSecret || "",
authMethod: item.authMethod || "bearer",
authToken: item.authToken || "",
username: item.username || "",
password: item.password || "",
posTerminalId: item.posTerminalId || "",
posStoreCode: item.posStoreCode || "",
posApiKey: item.posApiKey || "",
posApiSecret: item.posApiSecret || "",
appId: item.appId || "",
bundleIdentifier: item.bundleIdentifier || "",
gatewayUrl: item.gatewayUrl || "",
customApiUrl: item.customApiUrl || "",
customApiHeaderKey: item.customApiHeaderKey || "",
customApiHeaderValue: item.customApiHeaderValue || "",
});
}
setLoadingItem(false);
}).catch(() => setLoadingItem(false));
}
}, [id, isEdit]);
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
formik.setFieldValue("channel", val);
// Auto-match integrationType to channelType from Channel Master
const selectedChan = channels.find(c => c.code === val || c.id === val);
if (selectedChan && selectedChan.channelType) {
formik.setFieldValue("integrationType", selectedChan.channelType);
}
};
const inputClass = (error?: boolean) =>
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-gray-200 focus:ring-purple-500"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white`;
@@ -99,7 +201,12 @@ export default function NewIntegration() {
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Channel</span>
<span className="font-medium text-gray-900 text-right">{formik.values.channel || "—"}</span>
<span className="font-medium text-gray-900 text-right">
{(() => {
const chan = channels.find(c => c.code === formik.values.channel || c.id === formik.values.channel);
return chan ? chan.name : (formik.values.channel || "—");
})()}
</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Type</span>
@@ -111,7 +218,9 @@ export default function NewIntegration() {
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Sync Direction</span>
<span className="font-medium text-gray-900">PIM </span>
<span className="font-medium text-gray-900">
{formik.values.syncDirection === "pim_to_channel" ? "PIM → Channel" : formik.values.syncDirection === "channel_to_pim" ? "Channel → PIM" : "Bidirectional"}
</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-gray-500 shrink-0">Sync Frequency</span>
@@ -131,13 +240,38 @@ export default function NewIntegration() {
</div>
);
if (loadingItem) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center">
<div className="w-10 h-10 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
<p className="mt-4 text-sm text-gray-600">Loading Integration...</p>
</div>
</div>
);
}
return (
<PageWrapper>
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Integrations', href: '/integrations' }, { label: 'Create Integration' }]}
backTo="/integrations"
actions={
<>
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Top Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4 sticky top-0 z-10">
<nav className="text-xs text-gray-500 mb-2 flex items-center gap-1.5">
<span className="cursor-pointer hover:text-gray-700" onClick={() => navigate("..")}>Integration Hub</span>
<span></span>
<span className="text-gray-900">{isEdit ? "Edit Integration" : "Create Integration"}</span>
</nav>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate("..")}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-gray-500"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-xl font-bold text-gray-900">{isEdit ? "Edit Integration" : "New Integration"}</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
className="flex items-center gap-2 py-2 px-4 border border-amber-300 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
@@ -147,7 +281,7 @@ export default function NewIntegration() {
</button>
<Button variant="outline" type="button" onClick={() => navigate('..')}>Cancel</Button>
{activeTab >= 2 && (
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">Save Integration</Button>
<Button type="button" onClick={() => formik.submitForm()} loading={formik.isSubmitting} className="bg-purple-600 hover:bg-purple-700 text-white">{isEdit ? "Update Integration" : "Save Integration"}</Button>
)}
</>
}
@@ -202,14 +336,16 @@ export default function NewIntegration() {
<select
name="channel"
value={formik.values.channel}
onChange={formik.handleChange}
onChange={handleChannelChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.channel && Boolean(formik.errors.channel))}
>
<option value="">Select a channel from Channel Master...</option>
<option value="shopify_main">Shopify Main</option>
<option value="amazon_in">Amazon India</option>
<option value="woocommerce">WooCommerce</option>
{channels.map((chan) => (
<option key={chan.id} value={chan.code || chan.id}>
{chan.name} ({chan.code})
</option>
))}
</select>
</div>
<p className="text-xs text-gray-400 mt-1">Channels are defined in Channel Master. Integration Hub stores connection credentials separately.</p>
@@ -270,9 +406,9 @@ export default function NewIntegration() {
onChange={formik.handleChange}
className={inputClass()}
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="Connected">Connected</option>
<option value="Pending">Pending</option>
<option value="Disconnected">Disconnected</option>
</select>
</div>
</div>
@@ -292,92 +428,423 @@ export default function NewIntegration() {
</div>
)}
{/* Tab 1: Connection Configuration */}
{/* Tab 1: Connection Configuration (Dynamic based on selected integrationType) */}
{activeTab === 1 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900 text-base">Connection Configuration</h2>
<span className="px-2.5 py-1 text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200 rounded-lg">
{selectedType?.label || "E-Commerce"}
<span className="px-2.5 py-1 text-xs font-medium bg-purple-50 text-purple-700 border border-purple-200 rounded-lg capitalize">
{selectedType?.label || "Custom API"}
</span>
</div>
<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>Shopify / WooCommerce provide your store URL and private app access token. API credentials are encrypted at rest.</p>
<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 === "marketplace" ? (
"Marketplace connection requires Seller ID, Marketplace ID, and Selling Partner API credentials."
) : formik.values.integrationType === "erp" || formik.values.integrationType === "wms" ? (
"Specify the API server URL and select the preferred authentication scheme."
) : formik.values.integrationType === "pos" ? (
"Configure Point-of-Sale terminal links, store identifiers, and connection secrets."
) : 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."
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Store URL *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
onChange={formik.handleChange}
placeholder="https://your-store.myshopify.com"
className={inputClass()}
/>
</div>
{/* Ecommerce / Website / B2B Portal Fields */}
{(formik.values.integrationType === "ecommerce" || formik.values.integrationType === "website" || formik.values.integrationType === "b2b_portal") && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Store / Website URL *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
onChange={formik.handleChange}
placeholder="https://your-store.myshopify.com"
className={inputClass()}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Access Token *</label>
<div className="relative">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Access Token *</label>
<div className="relative">
<input
name="accessToken"
type={showToken ? "text" : "password"}
value={formik.values.accessToken}
onChange={formik.handleChange}
placeholder="shpat_xxxxxxxxxxx"
className={inputClass() + " pr-10"}
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{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-gray-700 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-gray-700 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-gray-700 mb-1.5">Shop Identifier</label>
<input
name="shopIdentifier"
value={formik.values.shopIdentifier}
onChange={formik.handleChange}
placeholder="e.g., store_main"
className={inputClass()}
/>
</div>
</div>
</>
)}
{/* Marketplace Fields */}
{formik.values.integrationType === "marketplace" && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Seller ID / Merchant Token *</label>
<input
name="sellerId"
value={formik.values.sellerId}
onChange={formik.handleChange}
placeholder="e.g. A329XXXXXX"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Marketplace ID *</label>
<input
name="marketplaceId"
value={formik.values.marketplaceId}
onChange={formik.handleChange}
placeholder="e.g. A21TJRUUNF56AR"
className={inputClass()}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">AWS Access Key ID / Client ID *</label>
<input
name="awsAccessKeyId"
value={formik.values.awsAccessKeyId}
onChange={formik.handleChange}
placeholder="AKIAIOSFODNN7EXAMPLE"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">AWS Secret Key / Client Secret *</label>
<input
name="awsSecretKey"
type={showToken ? "text" : "password"}
value={formik.values.awsSecretKey}
onChange={formik.handleChange}
placeholder="AWS Secret Key"
className={inputClass()}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">LWA Client ID</label>
<input
name="clientId"
value={formik.values.clientId}
onChange={formik.handleChange}
placeholder="amzn1.application-oa2-client.xx"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">LWA Client Secret</label>
<input
name="clientSecret"
type="password"
value={formik.values.clientSecret}
onChange={formik.handleChange}
placeholder="Secret key"
className={inputClass()}
/>
</div>
</div>
</>
)}
{/* ERP / WMS Fields */}
{(formik.values.integrationType === "erp" || formik.values.integrationType === "wms") && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Endpoint URL / Host Address *</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
onChange={formik.handleChange}
placeholder="https://erp.mycompany.internal/api/v1"
className={inputClass()}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Auth Method *</label>
<select
name="authMethod"
value={formik.values.authMethod}
onChange={formik.handleChange}
className={inputClass()}
>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth (User/Password)</option>
<option value="apikey">API Key Header</option>
</select>
</div>
{formik.values.authMethod === "bearer" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">API Token *</label>
<input
name="authToken"
type="password"
value={formik.values.authToken}
onChange={formik.handleChange}
placeholder="Bearer token"
className={inputClass()}
/>
</div>
)}
{formik.values.authMethod === "apikey" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">API Key *</label>
<input
name="authToken"
type="password"
value={formik.values.authToken}
onChange={formik.handleChange}
placeholder="x-api-key"
className={inputClass()}
/>
</div>
)}
</div>
{formik.values.authMethod === "basic" && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Username *</label>
<input
name="username"
value={formik.values.username}
onChange={formik.handleChange}
placeholder="Username"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Password *</label>
<input
name="password"
type="password"
value={formik.values.password}
onChange={formik.handleChange}
placeholder="Password"
className={inputClass()}
/>
</div>
</div>
)}
</>
)}
{/* POS Fields */}
{formik.values.integrationType === "pos" && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">POS Store Code *</label>
<input
name="posStoreCode"
value={formik.values.posStoreCode}
onChange={formik.handleChange}
placeholder="e.g. STORE-LONDON-01"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Terminal ID *</label>
<input
name="posTerminalId"
value={formik.values.posTerminalId}
onChange={formik.handleChange}
placeholder="e.g. TERM-104"
className={inputClass()}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Register API Key *</label>
<input
name="posApiKey"
value={formik.values.posApiKey}
onChange={formik.handleChange}
placeholder="POS Client Key"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Register API Secret *</label>
<input
name="posApiSecret"
type="password"
value={formik.values.posApiSecret}
onChange={formik.handleChange}
placeholder="POS Client Secret"
className={inputClass()}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">POS Local Gateway URL</label>
<input
name="storeUrl"
value={formik.values.storeUrl}
onChange={formik.handleChange}
placeholder="http://192.168.1.50:8080/pos-api"
className={inputClass()}
/>
</div>
</>
)}
{/* Mobile App Fields */}
{formik.values.integrationType === "mobile_app" && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">App ID / Client ID *</label>
<input
name="appId"
value={formik.values.appId}
onChange={formik.handleChange}
placeholder="e.g. maskanza-catalog-ios"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Bundle / Package Identifier *</label>
<input
name="bundleIdentifier"
value={formik.values.bundleIdentifier}
onChange={formik.handleChange}
placeholder="com.maskanza.catalog"
className={inputClass()}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Mobile Sync Endpoint *</label>
<input
name="gatewayUrl"
value={formik.values.gatewayUrl}
onChange={formik.handleChange}
placeholder="https://mobile.maskanza.com/api/sync"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Access API Key</label>
<input
name="accessToken"
type={showToken ? "text" : "password"}
type="password"
value={formik.values.accessToken}
onChange={formik.handleChange}
placeholder="shpat_xxxxxxxxxxx"
className={inputClass() + " pr-10"}
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{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-gray-700 mb-1.5">API Version</label>
<div className="relative">
<select
name="apiVersion"
value={formik.values.apiVersion}
onChange={formik.handleChange}
placeholder="Private App Key"
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>
</>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Webhook Secret</label>
<input
name="webhookSecret"
value={formik.values.webhookSecret}
onChange={formik.handleChange}
placeholder="Optional webhook verification secret"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Shop Identifier</label>
<input
name="shopIdentifier"
value={formik.values.shopIdentifier}
onChange={formik.handleChange}
placeholder="your-store"
className={inputClass()}
/>
</div>
</div>
{/* Custom API Fields */}
{formik.values.integrationType === "custom_api" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Base API Gateway URL *</label>
<input
name="customApiUrl"
value={formik.values.customApiUrl}
onChange={formik.handleChange}
placeholder="https://api.externalpartner.com/v2"
className={inputClass()}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Custom Header Name</label>
<input
name="customApiHeaderKey"
value={formik.values.customApiHeaderKey}
onChange={formik.handleChange}
placeholder="X-Partner-Token"
className={inputClass()}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">Custom Header Value</label>
<input
name="customApiHeaderValue"
type="password"
value={formik.values.customApiHeaderValue}
onChange={formik.handleChange}
placeholder="token-value"
className={inputClass()}
/>
</div>
</div>
</>
)}
</div>
)}
@@ -546,7 +1013,7 @@ export default function NewIntegration() {
loading={formik.isSubmitting}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
Save Integration
{isEdit ? "Update Integration" : "Save Integration"}
</Button>
)}
</div>
@@ -2,9 +2,91 @@ import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest }
const STORAGE_KEY = 'pim_integrations';
const INITIAL_DATA: Integration[] = [
{
id: "1",
name: "Amazon India Production",
description: "Primary Amazon India marketplace connection for main product listings",
channel: "amazon_us",
integrationType: "marketplace",
environment: "production",
status: "Connected",
syncDirection: "pim_to_channel",
syncFrequency: "hourly",
autoRetry: true,
retryAttempts: 3,
lastSync: "2026-06-29 10:15",
syncErrors: 3,
published: "4,240",
author: "Sarah Chen",
createdAt: "2025-01-10T08:00:00Z"
},
{
id: "2",
name: "Amazon UAE Marketplace",
description: "Amazon UAE marketplace for Middle East region",
channel: "amazon_us",
integrationType: "marketplace",
environment: "production",
status: "Connected",
syncDirection: "pim_to_channel",
syncFrequency: "daily",
autoRetry: true,
retryAttempts: 2,
lastSync: "2026-06-29 09:30",
syncErrors: 0,
published: "1,840",
author: "Michael Torres",
createdAt: "2025-02-14T08:00:00Z"
},
{
id: "3",
name: "Shopify Main Storefront",
description: "Main Shopify storefront with custom themes",
channel: "shopify_main",
integrationType: "ecommerce",
environment: "production",
status: "Connected",
storeUrl: "https://maskanza-main.myshopify.com",
accessToken: "shpat_xxxxxxxxxxxxxxxx",
apiVersion: "2025-01",
shopIdentifier: "maskanza-main",
syncDirection: "pim_to_channel",
syncFrequency: "realtime",
autoRetry: true,
retryAttempts: 5,
lastSync: "2026-06-29 10:45",
syncErrors: 12,
published: "8,942",
author: "Emma Wilson",
createdAt: "2025-03-01T08:00:00Z"
},
{
id: "4",
name: "Retail POS Network",
description: "Point-of-sale retail network product catalog syncer",
channel: "shopify_main",
integrationType: "pos",
environment: "production",
status: "Connected",
syncDirection: "pim_to_channel",
syncFrequency: "hourly",
autoRetry: false,
retryAttempts: 1,
lastSync: "2026-06-29 08:00",
syncErrors: 0,
published: "3,240",
author: "David Park",
createdAt: "2025-04-15T08:00:00Z"
}
];
const getStored = (): Integration[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(INITIAL_DATA));
return INITIAL_DATA;
}
return JSON.parse(stored);
};
@@ -1,8 +1,63 @@
export interface Integration {
id: string;
name: string;
status: 'active' | 'inactive';
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
// E-commerce/Website connection config
storeUrl?: string;
accessToken?: string;
apiVersion?: string;
webhookSecret?: string;
shopIdentifier?: string;
// Marketplace specific fields
sellerId?: string;
marketplaceId?: string;
awsAccessKeyId?: string;
awsSecretKey?: string;
clientId?: string;
clientSecret?: string;
// ERP/WMS specific fields
authMethod?: string;
authToken?: string;
username?: string;
password?: string;
// POS specific fields
posTerminalId?: string;
posStoreCode?: string;
posApiKey?: string;
posApiSecret?: string;
// Mobile App specific fields
appId?: string;
bundleIdentifier?: string;
gatewayUrl?: string;
// Custom API specific fields
customApiUrl?: string;
customApiHeaderKey?: string;
customApiHeaderValue?: string;
// Sync settings
syncDirection: string; // pim_to_channel, channel_to_pim, bidirectional
syncFrequency: string; // manual, hourly, daily, realtime
autoRetry: boolean;
retryAttempts: number;
// Listing page read-only / metadata fields
lastSync?: string;
syncErrors?: number;
published?: string | number;
author?: string;
createdAt: string;
}
export type IntegrationCreateRequest = Omit<Integration, 'id' | 'createdAt'>;
export type IntegrationUpdateRequest = Partial<IntegrationCreateRequest>;
+37 -52
View File
@@ -90,7 +90,7 @@ export default function NewProduct() {
/>
<div className="max-w-[1400px] w-full mx-auto">
{step === 1 && (
<div className="max-w-4xl mx-auto">
<div className="max-w-5xl">
<h2 className="text-sm font-semibold text-gray-900 mb-3">Choose Creation Method</h2>
<div className="flex gap-4 mb-8">
@@ -109,9 +109,11 @@ export default function NewProduct() {
<button
onClick={() => setCreationMethod('clone')}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 opacity-50 cursor-not-allowed border-gray-200 bg-gray-50`}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${creationMethod === 'clone' ? 'border-purple-500 bg-purple-50/50' : 'border-gray-200 bg-white hover:border-purple-200'}`}
>
<div className="w-4 h-4 rounded-full border-2 border-gray-300 flex items-center justify-center" />
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${creationMethod === 'clone' ? 'border-purple-600' : 'border-gray-300'}`}>
{creationMethod === 'clone' && <div className="w-2 h-2 rounded-full bg-purple-600" />}
</div>
<div>
<div className="font-semibold text-gray-900 text-sm">Clone Existing Product</div>
<div className="text-xs text-gray-500">Copy attributes from existing product</div>
@@ -119,51 +121,34 @@ export default function NewProduct() {
</button>
</div>
<h2 className="text-sm font-semibold text-gray-900 mb-3">Product Family</h2>
<div className="space-y-4">
{MOCK_FAMILIES.map(family => (
<div
key={family.id}
onClick={() => { setSelectedFamily(family.id); handleNext(); }}
className="bg-white border border-gray-200 rounded-xl p-5 hover:border-purple-400 hover:shadow-md transition-all cursor-pointer"
>
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="text-lg font-bold text-gray-900">{family.name}</h3>
<p className="text-xs font-medium text-gray-500">{family.code}</p>
</div>
<div className="w-9 h-9 rounded-lg bg-amber-100 flex items-center justify-center">
<Briefcase className="w-4.5 h-4.5 text-amber-700" />
</div>
</div>
<p className="text-sm text-gray-600 mb-4">{family.desc}</p>
<div className="grid grid-cols-4 gap-4 text-sm mb-4">
<div>
<div className="text-gray-500 text-xs">Variant Axes</div>
<div className="font-semibold text-gray-900">{family.axes}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Attribute Groups</div>
<div className="font-semibold text-gray-900">{family.attrGroups}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Channels</div>
<div className="font-semibold text-gray-900">{family.channels}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Workflow</div>
<div className="font-semibold text-gray-900">{family.workflow}</div>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-emerald-600 font-medium">
<CheckCircle2 className="w-3.5 h-3.5" />
Inherited: Variant Axes, Attribute Groups, Channels, Workflow
</div>
{creationMethod === 'family' && (
<>
<h2 className="text-sm font-semibold text-gray-900 mb-3">Product Family</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{MOCK_FAMILIES.map(family => (
<FamilyCard
key={family.id}
family={family}
selected={selectedFamily === family.id}
onSelect={(id) => { setSelectedFamily(id); handleNext(); }}
/>
))}
</div>
))}
</div>
</>
)}
{creationMethod === 'clone' && (
<div className="p-8 border-2 border-dashed border-gray-200 rounded-xl text-center">
<Copy className="w-8 h-8 text-gray-400 mx-auto mb-3" />
<h3 className="font-medium text-gray-900 mb-1">Search for a product to clone</h3>
<p className="text-sm text-gray-500 mb-4">You can copy all attributes and settings from an existing product.</p>
<input
type="text"
placeholder="Enter product name or SKU..."
className="max-w-md w-full border border-gray-200 focus:ring-purple-500 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white"
/>
</div>
)}
</div>
)}
@@ -730,14 +715,14 @@ export default function NewProduct() {
<p className="text-xs text-gray-500 mb-6">Track your product completion status</p>
<div className="flex justify-center mb-6">
<div className="relative w-32 h-32">
<div className="relative w-24 h-24">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="none" stroke="#f3f4f6" strokeWidth="8" />
<circle cx="50" cy="50" r="45" fill="none" stroke="#9333ea" strokeWidth="8" strokeDasharray="283" strokeDashoffset="277" strokeLinecap="round" />
<circle cx="50" cy="50" r="45" fill="none" stroke="#f3f4f6" strokeWidth="10" />
<circle cx="50" cy="50" r="45" fill="none" stroke="#9333ea" strokeWidth="10" strokeDasharray="283" strokeDashoffset="277" strokeLinecap="round" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-gray-900">2%</span>
<span className="text-[10px] text-gray-500 uppercase tracking-wider font-semibold">Complete</span>
<span className="text-xl font-bold text-gray-900">2%</span>
<span className="text-[9px] text-gray-500 uppercase tracking-wider font-semibold">Complete</span>
</div>
</div>
</div>
+4 -4
View File
@@ -10,10 +10,10 @@
@theme {
/* --- Brand / Primary --- */
--color-primary: #7c3aed; /* purple-600 — main brand */
--color-primary-hover: #6d28d9; /* purple-700 — hover state */
--color-primary-light: #a78bfa; /* purple-400 — light accent */
--color-primary-dark: #4c1d95; /* purple-900 — dark accent */
--color-primary: #9F2BF5; /* vibrant neon purple */
--color-primary-hover: #8b25d6; /* hover state */
--color-primary-light: #c284f9; /* light accent */
--color-primary-dark: #6814a8; /* dark accent */
/* --- Semantic --- */
--color-success: #10B981; /* emerald-500 */
+7
View File
@@ -2,6 +2,7 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthGuard } from '../authentication/components/ProtectedRoute';
import MainLayout from '../components/layouts/MainLayout';
import { Login, SignUp, ForgotPassword, ResetPassword } from '../authentication/routes';
// Feature Routes
import { DashboardRoutes } from '../features/dashboard/routes/dashboard.routes';
@@ -31,6 +32,12 @@ import { SettingRoutes } from '../features/settings/routes/settings.routes';
const AppRoutes = () => {
return (
<Routes>
{/* Public Auth Routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<SignUp />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Default Redirect */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
+62 -20
View File
@@ -1,20 +1,62 @@
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import { defaultPermissions } from './slices/authSlice';
// ─── localStorage persistence helpers ────────────────────────────────────────
const AUTH_KEY = 'pim_auth_state';
function loadAuthState() {
try {
const raw = localStorage.getItem(AUTH_KEY);
if (!raw) return undefined;
const parsed = JSON.parse(raw);
// If stored permissions is corrupt/invalid (e.g. the old [] array bug),
// always fall back to the hardcoded defaults so every page is accessible.
const hasValidPermissions =
parsed.permissions &&
!Array.isArray(parsed.permissions) &&
typeof parsed.permissions === 'object' &&
Object.keys(parsed.permissions).length > 0;
return {
auth: {
...parsed,
permissions: hasValidPermissions ? parsed.permissions : defaultPermissions,
},
};
} catch {
return undefined;
}
}
function saveAuthState(state: ReturnType<typeof rootReducer>) {
try {
localStorage.setItem(AUTH_KEY, JSON.stringify(state.auth));
} catch {
// ignore write errors
}
}
// ─────────────────────────────────────────────────────────────────────────────
export const store = configureStore({
reducer: rootReducer,
preloadedState: loadAuthState(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
// Sync auth state to localStorage on every change
store.subscribe(() => saveAuthState(store.getState()));
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Typed hooks
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+27 -24
View File
@@ -9,33 +9,35 @@ interface AuthState {
permissions: PermissionPayload;
}
export const defaultPermissions = {
"products.items": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"products.families": { view: true, create: true, edit: true, delete: true, alter: false, import: true, export: true },
"products.categories": { view: true, create: true, edit: true, delete: true, alter: false, import: true, export: true },
"products.variants": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"products.attributes": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.brands": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.units": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.scopes": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.manager": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.types": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.families": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"imports": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"workflow": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.master": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.integrations": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"users": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"reports": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"settings": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
} satisfies PermissionPayload;
const initialState: AuthState = {
isAuthenticated: true, // true = development mode (no login required)
isAuthenticated: false,
isLoading: false,
user: null,
permissions: {
"products.items": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"products.families": { view: true, create: true, edit: true, delete: true, alter: false, import: true, export: true },
"products.categories": { view: true, create: true, edit: true, delete: true, alter: false, import: true, export: true },
"products.variants": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"products.attributes": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.brands": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.units": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"masters.scopes": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.manager": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.types": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"assets.families": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"imports": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"workflow": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.master": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"channels.integrations": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"users": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"reports": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
"settings": { view: true, create: true, edit: true, delete: true, alter: true, import: true, export: true },
},
permissions: { ...defaultPermissions },
};
const authSlice = createSlice({
@@ -61,6 +63,7 @@ const authSlice = createSlice({
state.isAuthenticated = false;
state.user = null;
state.permissions = {};
localStorage.removeItem('pim_auth_state');
},
},
});