UI fixes and mobile responsive
This commit is contained in:
parent
59d90ed807
commit
4ad0ed7dbc
@ -19,8 +19,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Plus
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
interface ShopifyProduct {
|
interface ShopifyProduct {
|
||||||
id: number
|
id: number
|
||||||
@ -79,7 +83,15 @@ export default function InventoryPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
const [vendorFilter, setVendorFilter] = useState<string>("all")
|
const [vendorFilter, setVendorFilter] = useState<string>("all")
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({})
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [menuRect, setMenuRect] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const menuRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({})
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStoreInfo = async () => {
|
const fetchStoreInfo = async () => {
|
||||||
@ -151,8 +163,8 @@ export default function InventoryPage() {
|
|||||||
const connected = data.connections?.filter(
|
const connected = data.connections?.filter(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(c: any) => c.status === "CONNECTED" &&
|
(c: any) => c.status === "CONNECTED" &&
|
||||||
c.supplier &&
|
c.supplier &&
|
||||||
c.store?.id === activeStore.id
|
c.store?.id === activeStore.id
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
).map((c: any) => c.supplier) || []
|
).map((c: any) => c.supplier) || []
|
||||||
|
|
||||||
@ -201,8 +213,19 @@ export default function InventoryPage() {
|
|||||||
}
|
}
|
||||||
}, [openMenuId])
|
}, [openMenuId])
|
||||||
|
|
||||||
const handleMenuToggle = (productId: number) => {
|
const handleMenuToggle = (productId: number, e: React.MouseEvent) => {
|
||||||
setOpenMenuId(openMenuId === productId ? null : productId)
|
e.stopPropagation()
|
||||||
|
if (openMenuId === productId) {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setMenuRect(null)
|
||||||
|
} else {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
setMenuRect({
|
||||||
|
top: rect.bottom + window.scrollY,
|
||||||
|
left: rect.right + window.scrollX
|
||||||
|
})
|
||||||
|
setOpenMenuId(productId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewProduct = (product: ShopifyProduct) => {
|
const handleViewProduct = (product: ShopifyProduct) => {
|
||||||
@ -336,306 +359,402 @@ export default function InventoryPage() {
|
|||||||
const uniqueVendors = Array.from(new Set(products.map(p => p.vendor))).sort()
|
const uniqueVendors = Array.from(new Set(products.map(p => p.vendor))).sort()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 space-y-4 md:space-y-6">
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
<div className="flex flex-col h-full min-h-0 space-y-4 md:space-y-6">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Inventory</h1>
|
<div>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">Manage your synced Shopify products and variants.</p>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Inventory</h1>
|
||||||
</div>
|
<p className="text-sm md:text-base text-slate-400 mt-1">Manage your synced Shopify products and variants.</p>
|
||||||
<div className="flex gap-2 md:gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
className="h-10 md:h-11 px-3 md:px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-white hover:bg-white/15 backdrop-blur-sm transition-all flex items-center gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 flex flex-col flex-1 min-h-0 relative">
|
|
||||||
<div className="p-4 md:p-6 border-b border-white/10 flex flex-col sm:flex-row gap-3 md:gap-4 justify-between bg-white/5 flex-shrink-0">
|
|
||||||
<div className="relative flex-1 min-w-0">
|
|
||||||
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search products, vendors..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="flex gap-2 md:gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={handleExport}
|
||||||
className={`h-10 md:h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-slate-300 hover:bg-white/15 transition-all flex items-center justify-center gap-2 text-sm shrink-0 ${showFilters ? "bg-indigo-500/20 border-indigo-500/30 text-indigo-400" : ""}`}
|
className="h-10 md:h-11 px-3 md:px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-white hover:bg-white/15 backdrop-blur-sm transition-all flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" /> Filters
|
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
|
||||||
</button>
|
</button>
|
||||||
{showFilters && (
|
</div>
|
||||||
<div className="absolute right-0 top-full mt-2 bg-slate-900 border border-white/10 rounded-xl shadow-xl p-4 z-50 min-w-[200px] max-w-[calc(100vw-2rem)]">
|
</div>
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 flex flex-col flex-1 min-h-0 relative overflow-hidden">
|
||||||
<label className="block text-xs font-bold text-slate-400 mb-2">Status</label>
|
<div className="p-4 md:p-6 border-b border-white/10 flex flex-col sm:flex-row gap-3 md:gap-4 justify-between bg-white/5 flex-shrink-0">
|
||||||
<select
|
<div className="relative flex-1 min-w-0">
|
||||||
value={statusFilter}
|
<Search className="absolute left-3 md:left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
onChange={(e) => {
|
<input
|
||||||
setStatusFilter(e.target.value)
|
type="text"
|
||||||
}}
|
placeholder="Search products, vendors..."
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
value={searchTerm}
|
||||||
>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
<option value="all">All Statuses</option>
|
className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all"
|
||||||
<option value="active">Active</option>
|
/>
|
||||||
<option value="archived">Archived</option>
|
</div>
|
||||||
<option value="draft">Draft</option>
|
<div className="relative">
|
||||||
</select>
|
<button
|
||||||
</div>
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
<div>
|
className={`h-10 md:h-11 px-4 bg-white/10 border border-white/10 rounded-xl font-bold text-slate-300 hover:bg-white/15 transition-all flex items-center justify-center gap-2 text-sm shrink-0 ${showFilters ? "bg-indigo-500/20 border-indigo-500/30 text-indigo-400" : ""}`}
|
||||||
<label className="block text-xs font-bold text-slate-400 mb-2">Vendor</label>
|
>
|
||||||
<select
|
<Filter className="w-4 h-4" /> Filters
|
||||||
value={vendorFilter}
|
</button>
|
||||||
onChange={(e) => {
|
{showFilters && (
|
||||||
setVendorFilter(e.target.value)
|
<div className="absolute right-0 top-full mt-2 bg-slate-900 border border-white/10 rounded-xl shadow-xl p-4 z-50 min-w-[200px] max-w-[calc(100vw-2rem)]">
|
||||||
}}
|
<div className="space-y-3">
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
<div>
|
||||||
>
|
<label className="block text-xs font-bold text-slate-400 mb-2">Status</label>
|
||||||
<option value="all">All Vendors</option>
|
<select
|
||||||
{uniqueVendors.map(vendor => (
|
value={statusFilter}
|
||||||
<option key={vendor} value={vendor}>{vendor}</option>
|
onChange={(e) => {
|
||||||
))}
|
setStatusFilter(e.target.value)
|
||||||
</select>
|
}}
|
||||||
</div>
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
<button
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-400 mb-2">Vendor</label>
|
||||||
|
<select
|
||||||
|
value={vendorFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVendorFilter(e.target.value)
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
||||||
|
>
|
||||||
|
<option value="all">All Vendors</option>
|
||||||
|
{uniqueVendors.map(vendor => (
|
||||||
|
<option key={vendor} value={vendor}>{vendor}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter("all")
|
setStatusFilter("all")
|
||||||
setVendorFilter("all")
|
setVendorFilter("all")
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm hover:bg-white/10 transition-colors"
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm hover:bg-white/10 transition-colors"
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-auto -mx-4 md:mx-0 min-h-0">
|
|
||||||
<div className="min-w-full px-4 md:px-0">
|
|
||||||
{loading ? (
|
|
||||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
|
||||||
<p className="font-medium animate-pulse">Syncing with Shopify...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="h-64 flex flex-col items-center justify-center text-red-400 gap-4 p-8 text-center">
|
|
||||||
<div className="w-12 h-12 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center justify-center">
|
|
||||||
<AlertCircle className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold">{error}</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-xs mx-auto">Check your Shopify API permissions (read_products) and try again.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : filteredProducts.length === 0 ? (
|
|
||||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
|
||||||
<Package className="w-10 h-10 opacity-20" />
|
|
||||||
<p className="font-medium">No products found.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-white/5 border-b border-white/10">
|
|
||||||
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px] md:min-w-[300px]">Product</th>
|
|
||||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Status</th>
|
|
||||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Inventory</th>
|
|
||||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell text-left">Supplier</th>
|
|
||||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell text-left">Sync</th>
|
|
||||||
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-white/5">
|
|
||||||
{filteredProducts.map((product) => (
|
|
||||||
<tr key={product.id} className="hover:bg-white/5 transition-colors group">
|
|
||||||
<td className="px-4 md:px-8 py-3 md:py-4">
|
|
||||||
<div className="flex items-center gap-3 md:gap-4">
|
|
||||||
<div className="w-10 h-10 md:w-12 md:h-12 rounded-lg md:rounded-xl bg-white/10 border border-white/10 overflow-hidden shrink-0 flex items-center justify-center">
|
|
||||||
{product.image?.src ? (
|
|
||||||
<Image src={product.image.src} alt="" width={48} height={48} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<ImageIcon className="w-4 h-4 md:w-5 md:h-5 text-slate-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col min-w-0">
|
|
||||||
<span className="font-bold text-sm md:text-base text-white group-hover:text-indigo-400 transition-colors truncate">{product.title}</span>
|
|
||||||
<span className="text-[10px] font-medium text-slate-500 font-mono tracking-tighter">ID: {product.id}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4">
|
|
||||||
<span className={`px-2 md:px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${product.status === "active"
|
|
||||||
? "bg-emerald-500/20 text-emerald-400"
|
|
||||||
: "bg-white/10 text-slate-500"
|
|
||||||
}`}>
|
|
||||||
{product.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-bold text-sm md:text-base text-white">{product.variants.length} Variants</span>
|
|
||||||
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline">Total Sync Stock</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell text-left">
|
|
||||||
{(() => {
|
|
||||||
const sync = isProductSynced(product.id)
|
|
||||||
const syncStatus = getProductSyncStatus(product.id)
|
|
||||||
return sync ? (
|
|
||||||
<span className="text-sm font-medium text-white">{sync.supplier.companyName}</span>
|
|
||||||
) : syncStatus?.status === "PENDING" ? (
|
|
||||||
<span className="text-sm font-medium text-yellow-400">Pending...</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-medium text-slate-500">—</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell text-left">
|
|
||||||
{(() => {
|
|
||||||
const sync = isProductSynced(product.id)
|
|
||||||
const syncStatus = getProductSyncStatus(product.id)
|
|
||||||
return sync ? (
|
|
||||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-emerald-500/20 text-emerald-400">
|
|
||||||
Synced
|
|
||||||
</span>
|
|
||||||
) : syncStatus?.status === "PENDING" ? (
|
|
||||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-yellow-500/20 text-yellow-400">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSyncProduct(product)}
|
|
||||||
className="inline-block px-3 py-1.5 bg-indigo-500/20 text-indigo-400 border border-indigo-400/30 rounded-lg text-xs font-bold hover:bg-indigo-500/30 transition-colors"
|
|
||||||
>
|
|
||||||
Sync
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 md:px-8 py-3 md:py-4 text-right">
|
|
||||||
<div className="relative" ref={(el) => { menuRefs.current[product.id] = el }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMenuToggle(product.id)}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all relative z-10"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4 md:w-5 md:h-5" />
|
|
||||||
</button>
|
|
||||||
{openMenuId === product.id && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-xl overflow-hidden z-50 min-w-[180px]">
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewProduct(product)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
View in Shopify
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyProductId(product.id)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
Copy Product ID
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRefreshProduct()}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
Refresh Product
|
|
||||||
</button>
|
|
||||||
<div className="border-t border-white/10 my-1"></div>
|
|
||||||
{storeDomain && (
|
|
||||||
<a
|
|
||||||
href={`https://${storeDomain}/products/${product.handle}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() => setOpenMenuId(null)}
|
|
||||||
className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
View on Storefront
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination removed as per requirements */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Supplier Selection Modal */}
|
|
||||||
{showSupplierModal && selectedProduct && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white">Select Supplier</h2>
|
|
||||||
<p className="text-sm text-slate-400 mt-1">Choose a supplier to sync "{selectedProduct.title}"</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowSupplierModal(false)
|
|
||||||
setSelectedProduct(null)
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{connectedSuppliers.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<AlertCircle className="w-12 h-12 text-slate-500 mx-auto mb-4" />
|
|
||||||
<p className="text-slate-400 font-medium">No connected suppliers</p>
|
|
||||||
<p className="text-sm text-slate-500 mt-2">Please connect with suppliers first from the Suppliers page.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{connectedSuppliers.map((supplier) => (
|
|
||||||
<button
|
|
||||||
key={supplier.id}
|
|
||||||
onClick={() => handleSelectSupplier(supplier.id)}
|
|
||||||
disabled={syncingProduct}
|
|
||||||
className="p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 hover:border-indigo-500/50 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
Clear Filters
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-bold text-white">{supplier.companyName}</h3>
|
|
||||||
{supplier.description && (
|
|
||||||
<p className="text-sm text-slate-400 mt-1 line-clamp-2">{supplier.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-slate-500 mt-2">{supplier.user.email}</p>
|
|
||||||
</div>
|
|
||||||
{syncingProduct ? (
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-indigo-400 ml-4" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-indigo-400 ml-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-auto -mx-4 md:mx-0 min-h-0">
|
||||||
|
<div className="min-w-full px-4 md:px-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
|
<p className="font-medium animate-pulse">Syncing with Shopify...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-red-400 gap-4 p-8 text-center">
|
||||||
|
<div className="w-12 h-12 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{error}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 max-w-xs mx-auto">Check your Shopify API permissions (read_products) and try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredProducts.length === 0 ? (
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
|
<Package className="w-10 h-10 opacity-20" />
|
||||||
|
<p className="font-medium">No products found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white/5 border-b border-white/10">
|
||||||
|
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px] md:min-w-[300px]">Product</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Status</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Inventory</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell text-left">Supplier</th>
|
||||||
|
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell text-left">Sync</th>
|
||||||
|
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{filteredProducts.map((product) => (
|
||||||
|
<tr key={product.id} className="hover:bg-white/5 transition-colors group">
|
||||||
|
<td className="px-4 md:px-8 py-3 md:py-4">
|
||||||
|
<div className="flex items-center gap-3 md:gap-4">
|
||||||
|
<div className="w-10 h-10 md:w-12 md:h-12 rounded-lg md:rounded-xl bg-white/10 border border-white/10 overflow-hidden shrink-0 flex items-center justify-center">
|
||||||
|
{product.image?.src ? (
|
||||||
|
<Image src={product.image.src} alt="" width={48} height={48} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="w-4 h-4 md:w-5 md:h-5 text-slate-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="font-bold text-sm md:text-base text-white group-hover:text-indigo-400 transition-colors truncate">{product.title}</span>
|
||||||
|
<span className="text-[10px] font-medium text-slate-500 font-mono tracking-tighter">ID: {product.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||||
|
<span className={`px-2 md:px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${product.status === "active"
|
||||||
|
? "bg-emerald-500/20 text-emerald-400"
|
||||||
|
: "bg-white/10 text-slate-500"
|
||||||
|
}`}>
|
||||||
|
{product.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-sm md:text-base text-white">{product.variants.length} Variants</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline">Total Sync Stock</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell text-left">
|
||||||
|
{(() => {
|
||||||
|
const sync = isProductSynced(product.id)
|
||||||
|
const syncStatus = getProductSyncStatus(product.id)
|
||||||
|
return sync ? (
|
||||||
|
<span className="text-sm font-medium text-white">{sync.supplier.companyName}</span>
|
||||||
|
) : syncStatus?.status === "PENDING" ? (
|
||||||
|
<span className="text-sm font-medium text-yellow-400">Pending...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-slate-500">—</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell text-left">
|
||||||
|
{(() => {
|
||||||
|
const sync = isProductSynced(product.id)
|
||||||
|
const syncStatus = getProductSyncStatus(product.id)
|
||||||
|
return sync ? (
|
||||||
|
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-emerald-500/20 text-emerald-400">
|
||||||
|
Synced
|
||||||
|
</span>
|
||||||
|
) : syncStatus?.status === "PENDING" ? (
|
||||||
|
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-yellow-500/20 text-yellow-400">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSyncProduct(product)}
|
||||||
|
className="inline-block px-3 py-1.5 bg-indigo-500/20 text-indigo-400 border border-indigo-400/30 rounded-lg text-xs font-bold hover:bg-indigo-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 md:px-8 py-3 md:py-4 text-right">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={(el) => { menuRefs.current[product.id] = el }}
|
||||||
|
onClick={(e) => handleMenuToggle(product.id, e)}
|
||||||
|
className={`p-2 rounded-lg transition-all relative z-10 ${openMenuId === product.id
|
||||||
|
? (isLight ? "bg-indigo-100 text-indigo-600" : "bg-white/10 text-indigo-400")
|
||||||
|
: (isLight ? "text-slate-400 hover:text-slate-600 hover:bg-slate-100" : "text-slate-400 hover:text-indigo-400 hover:bg-white/10")
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination removed as per requirements */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Selection Modal */}
|
||||||
|
{showSupplierModal && selectedProduct && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Select Supplier</h2>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">Choose a supplier to sync "{selectedProduct.title}"</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSupplierModal(false)
|
||||||
|
setSelectedProduct(null)
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{connectedSuppliers.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertCircle className="w-12 h-12 text-slate-500 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-400 font-medium">No connected suppliers</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-2">Please connect with suppliers first from the Suppliers page.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{connectedSuppliers.map((supplier) => (
|
||||||
|
<button
|
||||||
|
key={supplier.id}
|
||||||
|
onClick={() => handleSelectSupplier(supplier.id)}
|
||||||
|
disabled={syncingProduct}
|
||||||
|
className="p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 hover:border-indigo-500/50 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-white">{supplier.companyName}</h3>
|
||||||
|
{supplier.description && (
|
||||||
|
<p className="text-sm text-slate-400 mt-1 line-clamp-2">{supplier.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500 mt-2">{supplier.user.email}</p>
|
||||||
|
</div>
|
||||||
|
{syncingProduct ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-indigo-400 ml-4" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-indigo-400 ml-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mounted && createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{openMenuId !== null && menuRect !== null && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100]"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setMenuRect(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionsMenuContent
|
||||||
|
openMenuId={openMenuId}
|
||||||
|
menuRect={menuRect}
|
||||||
|
isLight={isLight}
|
||||||
|
products={products}
|
||||||
|
storeDomain={storeDomain}
|
||||||
|
handleViewProduct={handleViewProduct}
|
||||||
|
handleCopyProductId={handleCopyProductId}
|
||||||
|
handleRefreshProduct={handleRefreshProduct}
|
||||||
|
onClose={() => {
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setMenuRect(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionsMenuContent({
|
||||||
|
openMenuId,
|
||||||
|
menuRect,
|
||||||
|
isLight,
|
||||||
|
products,
|
||||||
|
storeDomain,
|
||||||
|
handleViewProduct,
|
||||||
|
handleCopyProductId,
|
||||||
|
handleRefreshProduct,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
openMenuId: number;
|
||||||
|
menuRect: { top: number; left: number };
|
||||||
|
isLight: boolean;
|
||||||
|
products: ShopifyProduct[];
|
||||||
|
storeDomain: string | null;
|
||||||
|
handleViewProduct: (product: ShopifyProduct) => void;
|
||||||
|
handleCopyProductId: (productId: number) => void;
|
||||||
|
handleRefreshProduct: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const product = products.find(p => p.id === openMenuId);
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
key="inventory-actions-menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: menuRect.top + 4,
|
||||||
|
left: menuRect.left - 180,
|
||||||
|
zIndex: 101,
|
||||||
|
width: 180
|
||||||
|
}}
|
||||||
|
className={`rounded-xl shadow-2xl border overflow-hidden ${isLight
|
||||||
|
? "bg-white border-slate-200"
|
||||||
|
: "bg-slate-900 border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewProduct(product)}
|
||||||
|
className={`w-full px-4 py-2.5 text-left text-sm font-medium flex items-center gap-2 transition-colors ${isLight
|
||||||
|
? "text-slate-700 hover:bg-slate-50 hover:text-indigo-600"
|
||||||
|
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
View in Shopify
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyProductId(openMenuId)}
|
||||||
|
className={`w-full px-4 py-2.5 text-left text-sm font-medium flex items-center gap-2 transition-colors ${isLight
|
||||||
|
? "text-slate-700 hover:bg-slate-50 hover:text-indigo-600"
|
||||||
|
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Copy Product ID
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRefreshProduct()}
|
||||||
|
className={`w-full px-4 py-2.5 text-left text-sm font-medium flex items-center gap-2 transition-colors ${isLight
|
||||||
|
? "text-slate-700 hover:bg-slate-50 hover:text-indigo-600"
|
||||||
|
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh Product
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{storeDomain && product.handle && (
|
||||||
|
<>
|
||||||
|
<div className={`border-t ${isLight ? "border-slate-100" : "border-white/5"}`} />
|
||||||
|
<a
|
||||||
|
href={`https://${storeDomain}/products/${product.handle}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={onClose}
|
||||||
|
className={`w-full px-4 py-2.5 text-left text-sm font-medium flex items-center gap-2 transition-colors ${isLight
|
||||||
|
? "text-slate-700 hover:bg-slate-50 hover:text-indigo-600"
|
||||||
|
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View on Storefront
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -205,8 +205,8 @@ export default function EmailTemplatesPage() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className={`w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 transition-all text-sm font-medium ${isLight
|
className={`w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 transition-all text-sm font-medium ${isLight
|
||||||
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
? "bg-white border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
||||||
: "bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500/50"
|
: "bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500/50"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -245,8 +245,8 @@ export default function EmailTemplatesPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => startEditTemplate(template)}
|
onClick={() => startEditTemplate(template)}
|
||||||
className={`backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border transition-all group text-left ${isLight
|
className={`backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border transition-all group text-left ${isLight
|
||||||
? "bg-white border-slate-200 hover:bg-slate-50 hover:border-indigo-300 shadow-sm"
|
? "bg-white border-slate-200 hover:bg-slate-50 hover:border-indigo-300 shadow-sm"
|
||||||
: "bg-white/5 border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30"
|
: "bg-white/5 border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
@ -300,272 +300,276 @@ export default function EmailTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{editing && mounted && createPortal(
|
<AnimatePresence>
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center pointer-events-none p-4">
|
{editing && (
|
||||||
{/* Backdrop */}
|
<div className="fixed inset-0 z-[100] flex items-center justify-center pointer-events-none p-4">
|
||||||
<motion.div
|
{/* Backdrop */}
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
onClick={() => !saving && setEditing(null)}
|
exit={{ opacity: 0 }}
|
||||||
className={`absolute inset-0 pointer-events-auto backdrop-blur-sm ${isLight ? "bg-black/40" : "bg-black/60"
|
key="backdrop"
|
||||||
}`}
|
onClick={() => !saving && setEditing(null)}
|
||||||
/>
|
className={`absolute inset-0 pointer-events-auto backdrop-blur-sm ${isLight ? "bg-black/40" : "bg-black/60"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||||
className={`relative flex w-full max-h-[90vh] max-w-3xl flex-col rounded-2xl border shadow-2xl pointer-events-auto ${isLight
|
key="modal"
|
||||||
? "bg-white text-slate-900 border-slate-200"
|
className={`relative flex w-full max-h-[90vh] max-w-3xl flex-col rounded-2xl border shadow-2xl pointer-events-auto ${isLight
|
||||||
: "bg-slate-900 text-slate-50 border-white/10"
|
? "bg-white text-slate-900 border-slate-200"
|
||||||
}`}
|
: "bg-slate-900 text-slate-50 border-white/10"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-between px-6 py-5 border-b ${isLight ? "border-slate-100" : "border-white/10"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<h2 className="text-xl font-bold">
|
className={`flex items-center justify-between px-6 py-5 border-b ${isLight ? "border-slate-100" : "border-white/10"
|
||||||
{editing.id ? "Edit template" : "New template"}
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className={`mt-1 text-sm ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Use {"{{variable}}"} placeholders to personalize replies.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditing(null)}
|
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${isLight
|
|
||||||
? "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
|
||||||
: "bg-white/10 hover:bg-white/15 text-slate-300"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5 rotate-45" />
|
<div>
|
||||||
</button>
|
<h2 className="text-xl font-bold">
|
||||||
</div>
|
{editing.id ? "Edit template" : "New template"}
|
||||||
|
</h2>
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
|
<p
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
className={`mt-1 text-sm ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
<div className="space-y-4">
|
}`}
|
||||||
<div>
|
>
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
Use {"{{variable}}"} placeholders to personalize replies.
|
||||||
}`}>
|
|
||||||
Template Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editing.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing((prev) =>
|
|
||||||
prev
|
|
||||||
? { ...prev, name: e.target.value }
|
|
||||||
: prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="e.g. Shipping Update"
|
|
||||||
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
|
||||||
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
|
||||||
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}>
|
|
||||||
Intent
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={editing.intent}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing((prev) =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
intent: e.target.value,
|
|
||||||
}
|
|
||||||
: prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
|
||||||
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
|
||||||
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{INTENT_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}>
|
|
||||||
Mood
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={editing.mood}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing((prev) =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
mood: e.target.value,
|
|
||||||
}
|
|
||||||
: prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
|
||||||
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
|
||||||
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{MOOD_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}>
|
|
||||||
Description (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editing.description || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing((prev) =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
description: e.target.value,
|
|
||||||
}
|
|
||||||
: prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Short summary for your team"
|
|
||||||
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
|
||||||
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
|
||||||
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`p-4 rounded-2xl border ${isLight ? "bg-indigo-50/50 border-indigo-100" : "bg-indigo-500/5 border-indigo-500/10"
|
|
||||||
}`}>
|
|
||||||
<p className={`text-xs font-bold uppercase tracking-wider mb-3 ${isLight ? "text-indigo-600" : "text-indigo-400"
|
|
||||||
}`}>
|
|
||||||
Available variables
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{[
|
|
||||||
{ key: "{{customer_name}}", label: "Customer's name" },
|
|
||||||
{ key: "{{customer_email}}", label: "Customer's email" },
|
|
||||||
{ key: "{{store_name}}", label: "Store name" },
|
|
||||||
{ key: "{{ticket_summary}}", label: "Email summary" }
|
|
||||||
].map((v) => (
|
|
||||||
<div key={v.key} className="flex items-center justify-between text-[11px]">
|
|
||||||
<code className={`px-2 py-0.5 rounded font-mono font-bold ${isLight ? "bg-white border border-indigo-200 text-indigo-700" : "bg-white/10 text-indigo-300"
|
|
||||||
}`}>
|
|
||||||
{v.key}
|
|
||||||
</code>
|
|
||||||
<span className={isLight ? "text-slate-500" : "text-slate-400"}>{v.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}>
|
|
||||||
Template body
|
|
||||||
</label>
|
|
||||||
<span className={`text-[10px] ${isLight ? "text-slate-400" : "text-slate-500"}`}>
|
|
||||||
Markdown & HTML supported
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={editing.body}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditing((prev) =>
|
|
||||||
prev ? { ...prev, body: e.target.value } : prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rows={8}
|
|
||||||
className={`w-full rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 p-4 font-mono text-xs leading-relaxed ${isLight
|
|
||||||
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
|
||||||
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
|
|
||||||
}`}>
|
|
||||||
Preview with example data
|
|
||||||
</label>
|
|
||||||
<div className={`rounded-xl border p-5 text-sm leading-relaxed whitespace-pre-wrap ${isLight
|
|
||||||
? "bg-slate-50 border-slate-200 text-slate-800"
|
|
||||||
: "bg-slate-950 border-white/5 text-slate-300 shadow-inner"
|
|
||||||
}`}>
|
|
||||||
{currentPreview || "Preview will appear here..."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`px-6 py-4 border-t flex items-center justify-between ${isLight ? "border-slate-100 bg-slate-50/50" : "border-white/10 bg-white/[0.02]"
|
|
||||||
}`}>
|
|
||||||
<p className={`text-[11px] font-medium ${isLight ? "text-slate-500" : "text-slate-500"}`}>
|
|
||||||
Autosaved to your draft templates.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(null)}
|
onClick={() => setEditing(null)}
|
||||||
className={`h-10 px-6 rounded-xl font-bold transition-all border ${isLight
|
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${isLight
|
||||||
? "bg-white text-slate-700 hover:bg-slate-50 border-slate-200"
|
? "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||||
: "bg-white/10 text-slate-300 hover:bg-white/20 border-white/10"
|
: "bg-white/10 hover:bg-white/15 text-slate-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Cancel
|
<Plus className="w-5 h-5 rotate-45" />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={() => void handleSave()}
|
|
||||||
className="h-10 px-6 bg-indigo-500 text-sm font-bold text-white hover:bg-indigo-600 border border-indigo-400/20 shadow-lg shadow-indigo-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 rounded-xl"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Save template"
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
|
||||||
</div>,
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
document.body
|
<div className="space-y-4">
|
||||||
)}
|
<div>
|
||||||
</AnimatePresence>
|
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Template Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, name: e.target.value }
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="e.g. Shipping Update"
|
||||||
|
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
||||||
|
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
||||||
|
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Intent
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editing.intent}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
intent: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
||||||
|
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
||||||
|
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{INTENT_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Mood
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editing.mood}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
mood: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
||||||
|
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
||||||
|
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{MOOD_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.description || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
description: e.target.value,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Short summary for your team"
|
||||||
|
className={`w-full h-11 px-4 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 ${isLight
|
||||||
|
? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400 focus:border-indigo-500"
|
||||||
|
: "bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:border-indigo-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`p-4 rounded-2xl border ${isLight ? "bg-indigo-50/50 border-indigo-100" : "bg-indigo-500/5 border-indigo-500/10"
|
||||||
|
}`}>
|
||||||
|
<p className={`text-xs font-bold uppercase tracking-wider mb-3 ${isLight ? "text-indigo-600" : "text-indigo-400"
|
||||||
|
}`}>
|
||||||
|
Available variables
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{[
|
||||||
|
{ key: "{{customer_name}}", label: "Customer's name" },
|
||||||
|
{ key: "{{customer_email}}", label: "Customer's email" },
|
||||||
|
{ key: "{{store_name}}", label: "Store name" },
|
||||||
|
{ key: "{{ticket_summary}}", label: "Email summary" }
|
||||||
|
].map((v) => (
|
||||||
|
<div key={v.key} className="flex items-center justify-between text-[11px]">
|
||||||
|
<code className={`px-2 py-0.5 rounded font-mono font-bold ${isLight ? "bg-white border border-indigo-200 text-indigo-700" : "bg-white/10 text-indigo-300"
|
||||||
|
}`}>
|
||||||
|
{v.key}
|
||||||
|
</code>
|
||||||
|
<span className={isLight ? "text-slate-500" : "text-slate-400"}>{v.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Template body
|
||||||
|
</label>
|
||||||
|
<span className={`text-[10px] ${isLight ? "text-slate-400" : "text-slate-500"}`}>
|
||||||
|
Markdown & HTML supported
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editing.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditing((prev) =>
|
||||||
|
prev ? { ...prev, body: e.target.value } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows={8}
|
||||||
|
className={`w-full rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/30 p-4 font-mono text-xs leading-relaxed ${isLight
|
||||||
|
? "bg-slate-50 border border-slate-200 text-slate-900 focus:border-indigo-500"
|
||||||
|
: "bg-white/5 border border-white/10 text-white focus:border-indigo-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className={`block text-xs font-bold uppercase tracking-wider ${isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
}`}>
|
||||||
|
Preview with example data
|
||||||
|
</label>
|
||||||
|
<div className={`rounded-xl border p-5 text-sm leading-relaxed whitespace-pre-wrap ${isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-800"
|
||||||
|
: "bg-slate-950 border-white/5 text-slate-300 shadow-inner"
|
||||||
|
}`}>
|
||||||
|
{currentPreview || "Preview will appear here..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`px-6 py-4 border-t flex items-center justify-between ${isLight ? "border-slate-100 bg-slate-50/50" : "border-white/10 bg-white/[0.02]"
|
||||||
|
}`}>
|
||||||
|
<p className={`text-[11px] font-medium ${isLight ? "text-slate-500" : "text-slate-500"}`}>
|
||||||
|
Autosaved to your draft templates.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className={`h-10 px-6 rounded-xl font-bold transition-all border ${isLight
|
||||||
|
? "bg-white text-slate-700 hover:bg-slate-50 border-slate-200"
|
||||||
|
: "bg-white/10 text-slate-300 hover:bg-white/20 border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
className="h-10 px-6 bg-indigo-500 text-sm font-bold text-white hover:bg-indigo-600 border border-indigo-400/20 shadow-lg shadow-indigo-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 rounded-xl"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save template"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,32 +4,36 @@ import { useEffect, useState } from "react"
|
|||||||
import { DocumentsSection } from "@/components/kb/DocumentsSection"
|
import { DocumentsSection } from "@/components/kb/DocumentsSection"
|
||||||
import { FaqSection } from "@/components/kb/FaqSection"
|
import { FaqSection } from "@/components/kb/FaqSection"
|
||||||
import { AskSection } from "@/components/kb/AskSection"
|
import { AskSection } from "@/components/kb/AskSection"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export default function KnowledgeBasePage() {
|
export default function KnowledgeBasePage() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
|
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/stores")
|
const res = await fetch("/api/stores")
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
stores?: { id: string; isActive: boolean }[]
|
stores?: { id: string; isActive: boolean }[]
|
||||||
}
|
}
|
||||||
const active = (data.stores || []).find((s) => s.isActive)
|
const active = (data.stores || []).find((s) => s.isActive)
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setActiveStoreId(active?.id ?? null)
|
setActiveStoreId(active?.id ?? null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setActiveStoreId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
})()
|
||||||
if (!cancelled) {
|
|
||||||
setActiveStoreId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@ -46,10 +50,10 @@ export default function KnowledgeBasePage() {
|
|||||||
<div className="flex flex-col h-full space-y-6">
|
<div className="flex flex-col h-full space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">
|
<h1 className={cn("text-2xl font-bold", isLight ? "text-slate-900" : "text-white")}>
|
||||||
Knowledge Base
|
Knowledge Base
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<p className={cn("text-sm", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Centralize your store's documents and FAQs so JUNO can
|
Centralize your store's documents and FAQs so JUNO can
|
||||||
answer customer and vendor questions.
|
answer customer and vendor questions.
|
||||||
</p>
|
</p>
|
||||||
@ -59,10 +63,10 @@ export default function KnowledgeBasePage() {
|
|||||||
{!hasStore ? (
|
{!hasStore ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<h2 className="text-lg font-semibold text-white mb-2">
|
<h2 className={cn("text-lg font-semibold mb-2", isLight ? "text-slate-900" : "text-white")}>
|
||||||
No active store selected
|
No active store selected
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400">
|
<p className={cn("text-sm", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Select a store from the top bar to manage its knowledge
|
Select a store from the top bar to manage its knowledge
|
||||||
base.
|
base.
|
||||||
</p>
|
</p>
|
||||||
@ -70,37 +74,46 @@ export default function KnowledgeBasePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center space-x-2 border-b border-white/10">
|
<div className={cn("flex items-center space-x-2 border-b", isLight ? "border-slate-100" : "border-white/10")}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("documents")}
|
onClick={() => setActiveTab("documents")}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||||
activeTab === "documents"
|
activeTab === "documents"
|
||||||
? "border-indigo-500 text-white"
|
? "border-indigo-500 text-indigo-600"
|
||||||
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
: isLight
|
||||||
}`}
|
? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
|
||||||
|
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Documents
|
Documents
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("faqs")}
|
onClick={() => setActiveTab("faqs")}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||||
activeTab === "faqs"
|
activeTab === "faqs"
|
||||||
? "border-indigo-500 text-white"
|
? "border-indigo-500 text-indigo-600"
|
||||||
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
: isLight
|
||||||
}`}
|
? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
|
||||||
|
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
FAQs
|
FAQs
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("ask")}
|
onClick={() => setActiveTab("ask")}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||||
activeTab === "ask"
|
activeTab === "ask"
|
||||||
? "border-indigo-500 text-white"
|
? "border-indigo-500 text-indigo-600"
|
||||||
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
: isLight
|
||||||
}`}
|
? "border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300"
|
||||||
|
: "border-transparent text-slate-400 hover:text-white hover:border-slate-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Ask
|
Ask
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -62,14 +62,13 @@ export default function JunoEngineLayout({
|
|||||||
<button
|
<button
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
onClick={() => router.push(tab.href)}
|
onClick={() => router.push(tab.href)}
|
||||||
className={`pb-3 md:pb-4 px-3 md:px-4 font-bold transition-all duration-200 ease-out whitespace-nowrap text-sm md:text-base flex items-center gap-2 relative ${
|
className={`pb-3 md:pb-4 px-3 md:px-4 font-bold transition-all duration-200 ease-out whitespace-nowrap text-sm md:text-base flex items-center gap-2 relative ${isActive
|
||||||
isActive
|
|
||||||
? "text-indigo-400 border-b-2 border-indigo-500"
|
? "text-indigo-400 border-b-2 border-indigo-500"
|
||||||
: "text-slate-400 hover:text-white"
|
: "text-slate-400 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 transition-transform duration-200" />
|
<Icon className="w-4 h-5 md:w-5 md:h-5 transition-transform duration-200" />
|
||||||
{tab.label}
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Ticket, Loader2, MoreHorizontal } from "lucide-react"
|
import { ArrowLeft, Ticket, Loader2, MoreHorizontal } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type TicketStatus = "open" | "pending" | "resolved" | "closed"
|
type TicketStatus = "open" | "pending" | "resolved" | "closed"
|
||||||
|
|
||||||
@ -27,9 +28,13 @@ interface TicketData {
|
|||||||
messages: TicketMessage[]
|
messages: TicketMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
const params = useParams<{ ticketId: string }>()
|
const params = useParams<{ ticketId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [ticket, setTicket] = useState<TicketData | null>(null)
|
const [ticket, setTicket] = useState<TicketData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -69,65 +74,86 @@ export default function TicketDetailPage() {
|
|||||||
const getStatusStyles = (status: TicketStatus | string) => {
|
const getStatusStyles = (status: TicketStatus | string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "open":
|
case "open":
|
||||||
return "bg-blue-500/20 text-blue-400"
|
return isLight ? "bg-blue-100 text-blue-700 border-blue-200" : "bg-blue-500/20 text-blue-400 border-blue-500/20"
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-yellow-500/20 text-yellow-400"
|
return isLight ? "bg-amber-100 text-amber-700 border-amber-200" : "bg-yellow-500/20 text-yellow-400 border-yellow-500/20"
|
||||||
case "resolved":
|
case "resolved":
|
||||||
return "bg-emerald-500/20 text-emerald-400"
|
return isLight ? "bg-emerald-100 text-emerald-700 border-emerald-200" : "bg-emerald-500/20 text-emerald-400 border-emerald-500/20"
|
||||||
case "closed":
|
case "closed":
|
||||||
return "bg-slate-500/20 text-slate-400"
|
return isLight ? "bg-slate-100 text-slate-700 border-slate-200" : "bg-slate-500/20 text-slate-400 border-slate-500/20"
|
||||||
default:
|
default:
|
||||||
return "bg-white/10 text-slate-400"
|
return isLight ? "bg-slate-50 text-slate-600 border-slate-200" : "bg-white/10 text-slate-400 border-white/10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
<div className={cn("h-full flex flex-col min-h-0", isLight ? "bg-slate-50/50" : "")}>
|
||||||
{/* Header */}
|
<div className="w-full px-4 md:px-8 py-4 md:py-6 flex flex-col gap-6 min-h-0 h-full">
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
{/* Header */}
|
||||||
<button
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
onClick={() => router.push("/dashboard/juno-engine/tickets")}
|
<button
|
||||||
className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
onClick={() => router.push("/dashboard/juno-engine/tickets")}
|
||||||
>
|
className={cn(
|
||||||
<ArrowLeft className="w-4 h-4" />
|
"inline-flex items-center justify-center w-10 h-10 rounded-full border transition-all shadow-sm",
|
||||||
</button>
|
isLight
|
||||||
<div>
|
? "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-indigo-600"
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white"
|
||||||
{ticket?.subject || "Ticket"}
|
)}
|
||||||
</h1>
|
title="Go back"
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">
|
>
|
||||||
{ticket?.customerEmail || "Full automated email history for this customer."}
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</p>
|
</button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className={cn(
|
||||||
|
"text-2xl md:text-3xl font-black tracking-tight truncate",
|
||||||
|
isLight ? "text-slate-900" : "text-white"
|
||||||
|
)}>
|
||||||
|
{ticket?.subject || "Ticket"}
|
||||||
|
</h1>
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm mt-0.5 truncate font-medium",
|
||||||
|
isLight ? "text-slate-500" : "text-slate-400"
|
||||||
|
)}>
|
||||||
|
{ticket?.customerEmail || "Full automated email history for this customer."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-3">
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
<p className="font-medium animate-pulse">Loading ticket...</p>
|
<p className="font-medium animate-pulse">Loading ticket...</p>
|
||||||
</div>
|
</div>
|
||||||
) : error || !ticket ? (
|
) : error || !ticket ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-red-400 gap-3 text-center px-4">
|
<div className="flex-1 flex flex-col items-center justify-center text-red-400 gap-3 text-center px-4">
|
||||||
<p className="font-medium">{error || "Ticket not found."}</p>
|
<p className="font-medium">{error || "Ticket not found."}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 flex flex-col min-h-0 gap-8">
|
||||||
<div className="max-w-4xl mx-auto px-2 md:px-0 space-y-4">
|
|
||||||
{/* Meta badges */}
|
{/* Meta badges */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
|
<div className="flex flex-wrap items-center justify-between gap-4 text-xs flex-shrink-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${getStatusStyles(
|
className={cn(
|
||||||
ticket.status as TicketStatus
|
"px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider shadow-sm border",
|
||||||
)}`}
|
getStatusStyles(ticket.status as TicketStatus)
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{ticket.status}
|
{ticket.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-white/5 text-slate-300 border border-white/10">
|
<span className={cn(
|
||||||
|
"px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider shadow-sm border",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 text-slate-600"
|
||||||
|
: "bg-white/5 border-white/10 text-slate-300"
|
||||||
|
)}>
|
||||||
{ticket.messagesCount} messages
|
{ticket.messagesCount} messages
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-slate-500">
|
<div className={cn(
|
||||||
|
"text-[11px] font-bold",
|
||||||
|
isLight ? "text-slate-400" : "text-slate-500"
|
||||||
|
)}>
|
||||||
Updated{" "}
|
Updated{" "}
|
||||||
{new Date(ticket.updatedAt).toLocaleString(undefined, {
|
{new Date(ticket.updatedAt).toLocaleString(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
@ -136,97 +162,124 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversation timeline - chat style */}
|
{/* Conversation timeline - Full width corners chat style */}
|
||||||
<div className="pt-2 space-y-4">
|
<div className="flex-1 overflow-y-auto min-h-0 pr-4 -mr-4 space-y-8 pb-10 custom-scrollbar">
|
||||||
{ticket.messages.map((message) => {
|
{ticket.messages.map((message) => {
|
||||||
const isIncoming =
|
const isIncoming =
|
||||||
message.trigger === "GMAIL_INCOMING" || message.status === "RECEIVED"
|
message.trigger === "GMAIL_INCOMING" || message.status === "RECEIVED"
|
||||||
|
|
||||||
const fullText = message.bodyPreview || ""
|
const fullText = message.bodyPreview || ""
|
||||||
const lines = fullText.split(/\r?\n/)
|
const lines = fullText.split(/\r?\n/)
|
||||||
const isExpanded = !!expandedMessages[message.id]
|
const isExpanded = !!expandedMessages[message.id]
|
||||||
const hasMore = lines.length > 2
|
const hasMore = lines.length > 2
|
||||||
const displayText =
|
const displayText =
|
||||||
isExpanded || !hasMore ? fullText : lines.slice(0, 2).join("\n")
|
isExpanded || !hasMore ? fullText : lines.slice(0, 2).join("\n")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full animate-in fade-in slide-in-from-bottom-4 duration-500",
|
||||||
|
isIncoming ? "justify-start" : "justify-end"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
className={cn(
|
||||||
className={`flex w-full ${isIncoming ? "justify-start" : "justify-end"}`}
|
"w-full sm:max-w-[70%] lg:max-w-[60%] xl:max-w-[50%] rounded-2xl p-6 text-[14px] shadow-xl flex flex-col gap-3 relative transition-all border",
|
||||||
|
isIncoming
|
||||||
|
? isLight
|
||||||
|
? "bg-white border-slate-200 text-slate-700 rounded-bl-none shadow-slate-200/50"
|
||||||
|
: "bg-slate-800/40 border-white/10 text-slate-200 rounded-bl-none backdrop-blur-md shadow-black/20"
|
||||||
|
: isLight
|
||||||
|
? "bg-indigo-600 border-indigo-500 text-white rounded-br-none shadow-indigo-100"
|
||||||
|
: "bg-indigo-600 border-indigo-400/30 text-white rounded-br-none shadow-indigo-900/20"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center justify-between gap-6 mb-1">
|
||||||
className={`w-full sm:max-w-[88%] rounded-2xl px-4 py-3 text-xs shadow-sm flex flex-col gap-1
|
<div className="flex items-center gap-3">
|
||||||
${
|
<span className={cn(
|
||||||
|
"text-[11px] font-black uppercase tracking-widest",
|
||||||
isIncoming
|
isIncoming
|
||||||
? "bg-slate-900/70 border border-white/10 rounded-bl-sm"
|
? (isLight ? "text-indigo-600" : "text-indigo-400")
|
||||||
: "bg-indigo-500/90 border border-indigo-300/60 text-white rounded-br-sm"
|
: "text-indigo-100"
|
||||||
}`}
|
)}>
|
||||||
>
|
{isIncoming ? "Customer" : "JUNOHUB"}
|
||||||
<div className="flex items-center justify-between gap-3">
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<span
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide opacity-80">
|
className={cn(
|
||||||
{isIncoming ? "Customer" : "JUNOHUB"}
|
"px-2.5 py-0.5 rounded-md text-[9px] font-black tracking-widest uppercase border",
|
||||||
</span>
|
isIncoming
|
||||||
<span
|
? isLight
|
||||||
className={`px-2 py-0.5 rounded-full text-[9px] font-semibold tracking-wide
|
? "bg-slate-100 border-slate-200 text-slate-500"
|
||||||
${
|
: "bg-slate-700/50 text-slate-400 border-white/5"
|
||||||
isIncoming
|
: isLight
|
||||||
? "bg-slate-800/80 text-slate-200 border border-white/10"
|
? "bg-indigo-800/20 border-indigo-400/30 text-indigo-100"
|
||||||
: "bg-indigo-600/90 text-indigo-50 border border-indigo-300/70"
|
: "bg-indigo-700/50 text-indigo-50 border-indigo-400/30"
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{message.status}
|
{message.status}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-slate-400 whitespace-nowrap">
|
|
||||||
{new Date(message.sentAt).toLocaleTimeString(undefined, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className={cn(
|
||||||
{fullText && (
|
"text-[10px] whitespace-nowrap font-bold",
|
||||||
<p
|
isIncoming
|
||||||
className={`mt-0.5 leading-snug whitespace-pre-wrap break-words ${
|
? (isLight ? "text-slate-400" : "text-slate-500")
|
||||||
isIncoming ? "text-slate-200" : "text-indigo-50"
|
: "text-indigo-200"
|
||||||
}`}
|
)}>
|
||||||
>
|
{new Date(message.sentAt).toLocaleTimeString(undefined, {
|
||||||
{displayText}
|
hour: "2-digit",
|
||||||
</p>
|
minute: "2-digit",
|
||||||
)}
|
})}
|
||||||
|
</span>
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedMessages((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[message.id]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className={`self-end mt-1 text-[10px] font-medium underline underline-offset-2 ${
|
|
||||||
isIncoming ? "text-slate-300" : "text-indigo-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isExpanded ? "Show less" : "Show more"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{ticket.messages.length === 0 && (
|
{fullText && (
|
||||||
<p className="text-[11px] text-slate-500 text-center">
|
<p
|
||||||
No messages logged yet for this ticket.
|
className={cn(
|
||||||
</p>
|
"leading-relaxed whitespace-pre-wrap break-words font-medium",
|
||||||
)}
|
isIncoming
|
||||||
|
? (isLight ? "text-slate-600" : "text-slate-200")
|
||||||
|
: "text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedMessages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[message.id]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"self-start mt-2 text-[10px] font-black uppercase tracking-widest hover:underline transition-all",
|
||||||
|
isIncoming
|
||||||
|
? (isLight ? "text-indigo-600" : "text-indigo-400")
|
||||||
|
: "text-indigo-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isExpanded ? "Show less" : "Show more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{ticket.messages.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
|
||||||
|
<Ticket className="w-12 h-12 mb-4 opacity-10" />
|
||||||
|
<p className="text-sm font-medium">No messages logged yet for this ticket.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Users,
|
Users,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Loader2
|
Loader2,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
XAxis,
|
XAxis,
|
||||||
@ -18,8 +20,14 @@ import {
|
|||||||
AreaChart,
|
AreaChart,
|
||||||
Area
|
Area
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type TimeRange = "7days" | "30days" | "1year"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [stats, setStats] = useState([
|
const [stats, setStats] = useState([
|
||||||
{ label: "Total Sales", value: "$0.00", icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" },
|
{ label: "Total Sales", value: "$0.00", icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" },
|
||||||
{ label: "Active Products", value: "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" },
|
{ label: "Active Products", value: "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" },
|
||||||
@ -28,92 +36,179 @@ export default function DashboardPage() {
|
|||||||
])
|
])
|
||||||
const [chartData, setChartData] = useState<{ date: string; amount: number }[]>([])
|
const [chartData, setChartData] = useState<{ date: string; amount: number }[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>("7days")
|
||||||
|
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [productsRes, ordersRes, customersRes] = await Promise.all([
|
||||||
|
fetch("/api/shopify/inventory").catch(() => ({ ok: false, json: async () => ({ products: [], message: "Failed to fetch products" }) })),
|
||||||
|
fetch("/api/shopify/orders").catch(() => ({ ok: false, json: async () => ({ orders: [], message: "Failed to fetch orders" }) })),
|
||||||
|
fetch("/api/shopify/customers").catch(() => ({ ok: false, json: async () => ({ customers: [], message: "Failed to fetch customers" }) }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const [productsData, ordersData, customersData] = await Promise.all([
|
||||||
|
productsRes.json().catch(() => ({ products: [] })),
|
||||||
|
ordersRes.json().catch(() => ({ orders: [] })),
|
||||||
|
customersRes.json().catch(() => ({ customers: [] }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const orders = ordersData.orders || []
|
||||||
|
const totalSales = orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0
|
||||||
|
|
||||||
|
// Helper to generate date range
|
||||||
|
const generateDateRange = (range: TimeRange) => {
|
||||||
|
const dates = []
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (range === "1year") {
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
dates.push(d.toLocaleDateString("en-US", { month: "short", year: "2-digit" }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const count = range === "30days" ? 30 : 7
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i)
|
||||||
|
dates.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeLabels = generateDateRange(timeRange)
|
||||||
|
const salesByDate: Record<string, number> = {}
|
||||||
|
|
||||||
|
orders.forEach((order: { created_at?: string; total_price?: string }) => {
|
||||||
|
const orderDate = new Date(order.created_at ?? "")
|
||||||
|
let label = ""
|
||||||
|
if (timeRange === "1year") {
|
||||||
|
label = orderDate.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||||
|
} else {
|
||||||
|
label = orderDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeLabels.includes(label)) {
|
||||||
|
salesByDate[label] = (salesByDate[label] || 0) + parseFloat(order.total_price ?? "0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const processedChartData = rangeLabels.map(date => ({
|
||||||
|
date,
|
||||||
|
amount: Math.round((salesByDate[date] || 0) * 100) / 100
|
||||||
|
}))
|
||||||
|
|
||||||
|
setChartData(processedChartData)
|
||||||
|
|
||||||
|
setStats([
|
||||||
|
{ label: "Total Sales", value: `$${totalSales.toLocaleString()}`, icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" },
|
||||||
|
{ label: "Active Products", value: productsData.products?.length.toString() || "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" },
|
||||||
|
{ label: "Total Orders", value: orders.length.toString() || "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" },
|
||||||
|
{ label: "Customers", value: customersData.customers?.length.toString() || "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" },
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dashboard stats fetch error:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDashboardData = async () => {
|
|
||||||
try {
|
|
||||||
const [productsRes, ordersRes, customersRes] = await Promise.all([
|
|
||||||
fetch("/api/shopify/inventory").catch(() => ({ ok: false, json: async () => ({ products: [], message: "Failed to fetch products" }) })),
|
|
||||||
fetch("/api/shopify/orders").catch(() => ({ ok: false, json: async () => ({ orders: [], message: "Failed to fetch orders" }) })),
|
|
||||||
fetch("/api/shopify/customers").catch(() => ({ ok: false, json: async () => ({ customers: [], message: "Failed to fetch customers" }) }))
|
|
||||||
])
|
|
||||||
|
|
||||||
const [productsData, ordersData, customersData] = await Promise.all([
|
|
||||||
productsRes.json().catch(() => ({ products: [] })),
|
|
||||||
ordersRes.json().catch(() => ({ orders: [] })),
|
|
||||||
customersRes.json().catch(() => ({ customers: [] }))
|
|
||||||
])
|
|
||||||
|
|
||||||
// Check for error messages
|
|
||||||
if (productsData.message && !productsRes.ok) {
|
|
||||||
console.warn("Products fetch error:", productsData.message)
|
|
||||||
}
|
|
||||||
if (ordersData.message && !ordersRes.ok) {
|
|
||||||
console.warn("Orders fetch error:", ordersData.message)
|
|
||||||
}
|
|
||||||
if (customersData.message && !customersRes.ok) {
|
|
||||||
console.warn("Customers fetch error:", customersData.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const orders = ordersData.orders || []
|
|
||||||
const totalSales = orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0
|
|
||||||
|
|
||||||
// Process chart data (Recent 7 days sales)
|
|
||||||
const salesByDate: Record<string, number> = {}
|
|
||||||
orders.forEach((order: { created_at?: string; total_price?: string }) => {
|
|
||||||
const date = new Date(order.created_at ?? "").toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
||||||
salesByDate[date] = (salesByDate[date] || 0) + parseFloat(order.total_price ?? "0")
|
|
||||||
})
|
|
||||||
|
|
||||||
const processedChartData = Object.entries(salesByDate)
|
|
||||||
.map(([date, amount]) => ({ date, amount }))
|
|
||||||
.slice(-7) // Last 7 days
|
|
||||||
|
|
||||||
setChartData(processedChartData)
|
|
||||||
|
|
||||||
setStats([
|
|
||||||
{ label: "Total Sales", value: `$${totalSales.toLocaleString()}`, icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" },
|
|
||||||
{ label: "Active Products", value: productsData.products?.length.toString() || "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" },
|
|
||||||
{ label: "Total Orders", value: orders.length.toString() || "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" },
|
|
||||||
{ label: "Customers", value: customersData.customers?.length.toString() || "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" },
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Dashboard stats fetch error:", error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchDashboardData()
|
fetchDashboardData()
|
||||||
}, [])
|
}, [timeRange])
|
||||||
|
|
||||||
const statCardStyle: Record<string, { bg: string; icon: string }> = {
|
const statCardStyle: Record<string, { bg: string; icon: string; lightBg: string; lightIcon: string }> = {
|
||||||
"Total Sales": { bg: "bg-indigo-500/20 border-indigo-400/30", icon: "text-indigo-400" },
|
"Total Sales": {
|
||||||
"Active Products": { bg: "bg-emerald-500/20 border-emerald-400/30", icon: "text-emerald-400" },
|
bg: "bg-indigo-500/20 border-indigo-400/30",
|
||||||
"Total Orders": { bg: "bg-blue-500/20 border-blue-400/30", icon: "text-blue-400" },
|
icon: "text-indigo-400",
|
||||||
"Customers": { bg: "bg-orange-500/20 border-orange-400/30", icon: "text-orange-400" },
|
lightBg: "bg-indigo-50 border-indigo-100",
|
||||||
|
lightIcon: "text-indigo-600"
|
||||||
|
},
|
||||||
|
"Active Products": {
|
||||||
|
bg: "bg-emerald-500/20 border-emerald-400/30",
|
||||||
|
icon: "text-emerald-400",
|
||||||
|
lightBg: "bg-emerald-50 border-emerald-100",
|
||||||
|
lightIcon: "text-emerald-600"
|
||||||
|
},
|
||||||
|
"Total Orders": {
|
||||||
|
bg: "bg-blue-500/20 border-blue-400/30",
|
||||||
|
icon: "text-blue-400",
|
||||||
|
lightBg: "bg-blue-50 border-blue-100",
|
||||||
|
lightIcon: "text-blue-600"
|
||||||
|
},
|
||||||
|
"Customers": {
|
||||||
|
bg: "bg-orange-500/20 border-orange-400/30",
|
||||||
|
icon: "text-orange-400",
|
||||||
|
lightBg: "bg-orange-50 border-orange-100",
|
||||||
|
lightIcon: "text-orange-600"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-6 md:space-y-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Overview</h1>
|
<h1 className={cn("text-2xl md:text-3xl font-extrabold tracking-tight", isLight ? "text-slate-900" : "text-white")}>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">Welcome back! Here's a quick look at your live Shopify stats.</p>
|
Overview
|
||||||
|
</h1>
|
||||||
|
<p className={cn("text-sm md:text-base mt-1", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
|
Welcome back! Here's a quick look at your live Shopify stats.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative group">
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||||
|
className={cn(
|
||||||
|
"appearance-none h-11 pl-10 pr-10 rounded-xl font-bold border transition-all text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500/20",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 text-slate-700 hover:border-slate-300 shadow-sm"
|
||||||
|
: "bg-white/10 border-white/10 text-white hover:bg-white/15 backdrop-blur-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="7days">Last 7 Days</option>
|
||||||
|
<option value="30days">Last 1 Month</option>
|
||||||
|
<option value="1year">Last 1 Year</option>
|
||||||
|
</select>
|
||||||
|
<Calendar className={cn("absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4", isLight ? "text-slate-400" : "text-slate-400")} />
|
||||||
|
<ChevronDown className={cn("absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none", isLight ? "text-slate-400" : "text-slate-400")} />
|
||||||
|
</div>
|
||||||
|
<button className={cn(
|
||||||
|
"h-11 px-4 md:px-6 rounded-xl font-bold flex items-center justify-center gap-2 border transition-all text-sm md:text-base shrink-0",
|
||||||
|
isLight
|
||||||
|
? "bg-indigo-600 text-white border-indigo-700 hover:bg-indigo-700 shadow-sm"
|
||||||
|
: "bg-white/10 text-white border-white/10 hover:bg-white/15 backdrop-blur-sm"
|
||||||
|
)}>
|
||||||
|
Export <ArrowUpRight className="hidden sm:block w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="h-11 px-4 md:px-6 bg-white/10 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-white/15 border border-white/10 backdrop-blur-sm transition-all text-sm md:text-base shrink-0">
|
|
||||||
Export Report <ArrowUpRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
{stats.map((stat: (typeof stats)[number]) => (
|
{stats.map((stat: (typeof stats)[number]) => (
|
||||||
<div key={stat.label} className="bg-white/5 backdrop-blur-xl p-4 md:p-6 rounded-xl md:rounded-2xl border border-white/10 flex items-center gap-4 md:gap-6 hover:bg-white/[0.07] transition-colors">
|
<div key={stat.label} className={cn(
|
||||||
<div className={`w-12 h-12 md:w-14 md:h-14 rounded-xl md:rounded-2xl border ${statCardStyle[stat.label]?.bg ?? "bg-indigo-500/20 border-indigo-400/30"} flex items-center justify-center shrink-0 ${statCardStyle[stat.label]?.icon ?? "text-indigo-400"}`}>
|
"p-4 md:p-6 rounded-xl md:rounded-2xl border flex items-center gap-4 md:gap-6 transition-all hover:translate-y-[-2px]",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-100 shadow-sm hover:shadow-md"
|
||||||
|
: "bg-white/5 border-white/10 backdrop-blur-xl hover:bg-white/[0.07]"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-12 md:w-14 md:h-14 rounded-xl md:rounded-2xl border flex items-center justify-center shrink-0 shadow-inner",
|
||||||
|
isLight ? statCardStyle[stat.label]?.lightBg : statCardStyle[stat.label]?.bg,
|
||||||
|
isLight ? statCardStyle[stat.label]?.lightIcon : statCardStyle[stat.label]?.icon
|
||||||
|
)}>
|
||||||
{loading ? <Loader2 className="w-6 h-6 md:w-7 md:h-7 animate-spin" /> : <stat.icon className="w-6 h-6 md:w-7 md:h-7" />}
|
{loading ? <Loader2 className="w-6 h-6 md:w-7 md:h-7 animate-spin" /> : <stat.icon className="w-6 h-6 md:w-7 md:h-7" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-[10px] uppercase font-bold text-slate-400 tracking-widest">{stat.label}</p>
|
<p className={cn("text-[10px] uppercase font-bold tracking-widest", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
<p className={`text-xl md:text-2xl font-black text-white tracking-tight truncate ${loading ? "animate-pulse opacity-50" : ""}`}>
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
<p className={cn(
|
||||||
|
"text-xl md:text-2xl font-black tracking-tight truncate",
|
||||||
|
isLight ? "text-slate-900" : "text-white",
|
||||||
|
loading && "animate-pulse opacity-50"
|
||||||
|
)}>
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -121,58 +216,95 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/5 backdrop-blur-xl p-4 md:p-8 rounded-xl md:rounded-2xl border border-white/10">
|
<div className={cn(
|
||||||
<h3 className="text-lg md:text-xl font-bold text-white mb-4 md:mb-6 font-display">Recent Sales Trend</h3>
|
"p-4 md:p-8 rounded-xl md:rounded-2xl border transition-all",
|
||||||
<div className="h-[250px] md:h-[300px] w-full">
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-white/5 border-white/10 backdrop-blur-xl"
|
||||||
|
)}>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6 md:mb-8">
|
||||||
|
<div>
|
||||||
|
<h3 className={cn("text-lg md:text-xl font-bold font-display", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
Recent Sales Trend
|
||||||
|
</h3>
|
||||||
|
<p className={cn("text-xs mt-1", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
|
Revenue analysis over time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-slate-100 dark:bg-white/5 p-1 rounded-lg w-fit">
|
||||||
|
{(["7days", "30days", "1year"] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setTimeRange(r)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all",
|
||||||
|
timeRange === r
|
||||||
|
? "bg-white dark:bg-indigo-600 text-indigo-600 dark:text-white shadow-sm"
|
||||||
|
: "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r === "7days" ? "7D" : r === "30days" ? "1M" : "1Y"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[250px] md:h-[350px] w-full">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-white/5 border border-white/5">
|
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
) : chartData.length > 0 ? (
|
) : chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#818cf8" stopOpacity={0.25} />
|
<stop offset="5%" stopColor="#818cf8" stopOpacity={isLight ? 0.3 : 0.25} />
|
||||||
<stop offset="95%" stopColor="#4f46e5" stopOpacity={0} />
|
<stop offset="95%" stopColor="#818cf8" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(255,255,255,0.06)" />
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
stroke={isLight ? "rgba(0,0,0,0.04)" : "rgba(255,255,255,0.06)"}
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fill: "#94a3b8", fontSize: 10, fontWeight: 600 }}
|
tick={{ fill: isLight ? "#64748b" : "#94a3b8", fontSize: 10, fontWeight: 600 }}
|
||||||
dy={10}
|
dy={10}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fill: "#94a3b8", fontSize: 10, fontWeight: 600 }}
|
tick={{ fill: isLight ? "#64748b" : "#94a3b8", fontSize: 10, fontWeight: 600 }}
|
||||||
|
tickFormatter={(val) => `$${val > 999 ? (val / 1000).toFixed(1) + "k" : val}`}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: "16px",
|
borderRadius: "12px",
|
||||||
border: "1px solid rgba(255,255,255,0.1)",
|
border: isLight ? "1px solid #e2e8f0" : "1px solid rgba(255,255,255,0.1)",
|
||||||
background: "rgba(15, 23, 42, 0.9)",
|
background: isLight ? "#ffffff" : "#0f172a",
|
||||||
color: "#f1f5f9",
|
color: isLight ? "#1e293b" : "#f1f5f9",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
backdropFilter: "blur(12px)",
|
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
||||||
|
backdropFilter: isLight ? "none" : "blur(12px)",
|
||||||
}}
|
}}
|
||||||
|
formatter={(value: number | undefined) => [value !== undefined ? `$${value.toLocaleString()}` : "$0", "Revenue"]}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="amount"
|
dataKey="amount"
|
||||||
stroke="#818cf8"
|
stroke="#4f46e5"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
fill="url(#colorAmount)"
|
fill="url(#colorAmount)"
|
||||||
|
animationDuration={1500}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-white/5 border border-white/5 text-slate-400 font-semibold">
|
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 text-slate-400 font-semibold italic">
|
||||||
No sales data found for the last 7 days.
|
No sales data found for this period.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,12 @@ import { motion, AnimatePresence } from "framer-motion"
|
|||||||
import { X, Menu, Zap } from "lucide-react"
|
import { X, Menu, Zap } from "lucide-react"
|
||||||
import { Sidebar } from "./Sidebar"
|
import { Sidebar } from "./Sidebar"
|
||||||
|
|
||||||
|
import { useTheme } from "./ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function MobileSidebar() {
|
export function MobileSidebar() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@ -38,56 +43,86 @@ export function MobileSidebar() {
|
|||||||
{/* Hamburger Button */}
|
{/* Hamburger Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="md:hidden p-2 rounded-lg bg-white/10 border border-white/10 text-white hover:bg-white/15 transition-all"
|
className={cn(
|
||||||
|
"md:hidden p-2 rounded-lg transition-all border shadow-sm",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||||
|
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
||||||
|
)}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{isOpen && mounted && createPortal(
|
<AnimatePresence>
|
||||||
<>
|
{isOpen && (
|
||||||
<motion.div
|
<>
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.2 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] md:hidden pointer-events-auto"
|
key="sidebar-backdrop"
|
||||||
onClick={() => setIsOpen(false)}
|
transition={{ duration: 0.2 }}
|
||||||
/>
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[999] md:hidden pointer-events-auto"
|
||||||
<motion.div
|
onClick={() => setIsOpen(false)}
|
||||||
initial={{ x: -288 }}
|
/>
|
||||||
animate={{ x: 0 }}
|
<motion.div
|
||||||
exit={{ x: -288 }}
|
initial={{ x: -288 }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
animate={{ x: 0 }}
|
||||||
className="fixed inset-y-0 left-0 w-72 bg-slate-900/95 backdrop-blur-xl border-r border-white/10 z-[1000] md:hidden flex flex-col pointer-events-auto"
|
exit={{ x: -288 }}
|
||||||
>
|
key="sidebar-content"
|
||||||
{/* Header with close button */}
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
className={cn(
|
||||||
<div className="flex items-center gap-2">
|
"fixed inset-y-0 left-0 w-72 backdrop-blur-xl border-r z-[1000] md:hidden flex flex-col pointer-events-auto",
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
isLight
|
||||||
<Zap className="w-5 h-5 text-white" />
|
? "bg-white/95 border-slate-200"
|
||||||
|
: "bg-slate-900/95 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header with close button */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center justify-between p-4 border-b",
|
||||||
|
isLight ? "border-slate-100" : "border-white/10"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center backdrop-blur-sm border shadow-sm",
|
||||||
|
isLight
|
||||||
|
? "bg-indigo-50 border-indigo-100"
|
||||||
|
: "bg-indigo-500/40 border-white/20"
|
||||||
|
)}>
|
||||||
|
<Zap className={cn("w-5 h-5", isLight ? "text-indigo-600" : "text-white")} />
|
||||||
|
</div>
|
||||||
|
<h1 className={cn(
|
||||||
|
"text-xl font-bold tracking-tight",
|
||||||
|
isLight ? "text-slate-900" : "text-white"
|
||||||
|
)}>JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg transition-all border shadow-sm",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100"
|
||||||
|
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
||||||
|
)}
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Sidebar Content */}
|
||||||
onClick={() => setIsOpen(false)}
|
<div className="flex-1 overflow-y-auto">
|
||||||
className="p-2 rounded-lg bg-white/10 border border-white/10 text-white hover:bg-white/15 transition-all"
|
<Sidebar showLogo={false} className="!bg-transparent !border-none" />
|
||||||
aria-label="Close menu"
|
</div>
|
||||||
>
|
</motion.div>
|
||||||
<X className="w-5 h-5" />
|
</>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</AnimatePresence>,
|
||||||
{/* Sidebar Content */}
|
document.body
|
||||||
<div className="flex-1 overflow-y-auto">
|
)}
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
HelpCircle
|
HelpCircle
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@ -75,25 +76,47 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
showLogo?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ showLogo = true, className }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 py-4 flex flex-col h-full text-white">
|
<div className={cn(
|
||||||
<div className="px-6 py-2 flex items-center mb-6">
|
"space-y-4 py-4 flex flex-col h-full transition-colors duration-300",
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center mr-3 backdrop-blur-sm">
|
isLight ? "bg-white border-r border-slate-100 text-slate-900" : "text-white",
|
||||||
<Zap className="w-5 h-5 text-white fill-white" />
|
className
|
||||||
|
)}>
|
||||||
|
{showLogo && (
|
||||||
|
<div className="px-6 py-2 flex items-center mb-6">
|
||||||
|
<div className={cn(
|
||||||
|
"w-8 h-8 rounded-lg border flex items-center justify-center mr-3 backdrop-blur-sm",
|
||||||
|
isLight ? "bg-indigo-50 border-indigo-100" : "bg-indigo-500/40 border-white/20"
|
||||||
|
)}>
|
||||||
|
<Zap className={cn("w-5 h-5", isLight ? "text-indigo-600 fill-indigo-600" : "text-white fill-white")} />
|
||||||
|
</div>
|
||||||
|
<h1 className={cn("text-xl font-bold tracking-tight", isLight ? "text-slate-900" : "text-white")}>JUNOHUB</h1>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
||||||
{routes.map((route) => (
|
{routes.map((route) => (
|
||||||
<div key={route.href}>
|
<div key={route.href}>
|
||||||
<Link
|
<Link
|
||||||
href={route.href}
|
href={route.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:text-white hover:bg-white/10 rounded-xl transition-all duration-200 ease-out border border-transparent",
|
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer rounded-xl transition-all duration-200 ease-out border border-transparent",
|
||||||
pathname.startsWith(route.href) ? "text-white bg-white/10 border-white/10" : "text-slate-400"
|
isLight
|
||||||
|
? pathname.startsWith(route.href)
|
||||||
|
? "text-indigo-600 bg-indigo-50 border-indigo-100"
|
||||||
|
: "text-slate-600 hover:text-indigo-600 hover:bg-slate-50"
|
||||||
|
: pathname.startsWith(route.href)
|
||||||
|
? "text-white bg-white/10 border-white/10"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
@ -108,8 +131,14 @@ export function Sidebar() {
|
|||||||
key={sub.href}
|
key={sub.href}
|
||||||
href={sub.href}
|
href={sub.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs group flex p-2 w-full justify-start font-medium cursor-pointer hover:text-white hover:bg-white/5 rounded-lg transition-all duration-200 ease-out",
|
"text-xs group flex p-2 w-full justify-start font-medium cursor-pointer rounded-lg transition-all duration-200 ease-out",
|
||||||
pathname === sub.href ? "text-white bg-white/5" : "text-slate-500"
|
isLight
|
||||||
|
? pathname === sub.href
|
||||||
|
? "text-indigo-600 bg-indigo-50/50"
|
||||||
|
: "text-slate-500 hover:text-indigo-600 hover:bg-slate-50"
|
||||||
|
: pathname === sub.href
|
||||||
|
? "text-white bg-white/5"
|
||||||
|
: "text-slate-500 hover:text-white hover:bg-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
@ -123,12 +152,18 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 pt-4 border-t border-white/10">
|
<div className={cn("px-3 pt-4 border-t", isLight ? "border-slate-100" : "border-white/10")}>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/help-support"
|
href="/dashboard/help-support"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:text-white hover:bg-white/10 rounded-xl transition-all duration-200 ease-out border border-transparent",
|
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer rounded-xl transition-all duration-200 ease-out border border-transparent",
|
||||||
pathname === "/dashboard/help-support" ? "text-white bg-white/10 border-white/10" : "text-slate-400"
|
isLight
|
||||||
|
? pathname === "/dashboard/help-support"
|
||||||
|
? "text-blue-600 bg-blue-50 border-blue-100"
|
||||||
|
: "text-slate-600 hover:text-blue-600 hover:bg-slate-50"
|
||||||
|
: pathname === "/dashboard/help-support"
|
||||||
|
? "text-white bg-white/10 border-white/10"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type AskSectionProps = {
|
type AskSectionProps = {
|
||||||
storeId: string
|
storeId: string
|
||||||
@ -15,6 +17,8 @@ type Source = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AskSection({ storeId }: AskSectionProps) {
|
export function AskSection({ storeId }: AskSectionProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [includeFaq, setIncludeFaq] = useState(true)
|
const [includeFaq, setIncludeFaq] = useState(true)
|
||||||
const [includeDocuments, setIncludeDocuments] = useState(true)
|
const [includeDocuments, setIncludeDocuments] = useState(true)
|
||||||
@ -69,89 +73,118 @@ export function AskSection({ storeId }: AskSectionProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">Ask JUNO</h3>
|
<h3 className={cn("text-lg font-semibold", isLight ? "text-slate-900" : "text-white")}>Ask JUNO</h3>
|
||||||
<p className="text-xs text-slate-400">
|
<p className={cn("text-xs", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Ask questions across your store's documents and FAQs. The
|
Ask questions across your store's documents and FAQs. The
|
||||||
AI will answer using only your knowledge base.
|
AI will answer using only your knowledge base.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 bg-red-950/40 border border-red-900/50 rounded-md px-3 py-2">
|
<div className="text-xs text-red-500 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAsk}
|
onSubmit={handleAsk}
|
||||||
className="border border-white/5 rounded-xl bg-slate-900/40 backdrop-blur-sm p-4 space-y-3"
|
className={cn(
|
||||||
|
"border rounded-xl p-4 space-y-4 transition-all",
|
||||||
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-slate-900/40 border-white/5 backdrop-blur-sm"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="e.g. How long do customers have to return an item?"
|
placeholder="e.g. How long do customers have to return an item?"
|
||||||
className="w-full px-3 py-2 rounded-lg bg-slate-950/60 border border-white/10 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/70 focus:border-indigo-500/70 min-h-[80px]"
|
className={cn(
|
||||||
|
"w-full px-4 py-3 rounded-lg border text-sm transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500/50 min-h-[120px]",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-900 placeholder:text-slate-400"
|
||||||
|
: "bg-slate-950/60 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-4 text-xs text-slate-300">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4 text-xs">
|
||||||
<label className="inline-flex items-center gap-2">
|
<label className={cn(
|
||||||
|
"inline-flex items-center gap-2 cursor-pointer transition-colors",
|
||||||
|
isLight ? "text-slate-600 hover:text-slate-900" : "text-slate-400 hover:text-white"
|
||||||
|
)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="rounded border-slate-600 bg-slate-900 text-indigo-500"
|
className="rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500/20"
|
||||||
checked={includeDocuments}
|
checked={includeDocuments}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setIncludeDocuments(e.target.checked)
|
setIncludeDocuments(e.target.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Use documents</span>
|
<span className="font-medium">Use documents</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-2">
|
<label className={cn(
|
||||||
|
"inline-flex items-center gap-2 cursor-pointer transition-colors",
|
||||||
|
isLight ? "text-slate-600 hover:text-slate-900" : "text-slate-400 hover:text-white"
|
||||||
|
)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="rounded border-slate-600 bg-slate-900 text-indigo-500"
|
className="rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500/20"
|
||||||
checked={includeFaq}
|
checked={includeFaq}
|
||||||
onChange={(e) => setIncludeFaq(e.target.checked)}
|
onChange={(e) => setIncludeFaq(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Use FAQs</span>
|
<span className="font-medium">Use FAQs</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="inline-flex items-center px-3 py-2 rounded-lg bg-indigo-600/80 hover:bg-indigo-600 text-xs font-medium text-white disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
className="inline-flex items-center px-6 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-xs font-bold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
{isLoading ? "Thinking..." : "Ask"}
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Thinking...
|
||||||
|
</span>
|
||||||
|
) : "Ask JUNO"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{answer && (
|
{answer && (
|
||||||
<div className="border border-white/5 rounded-xl bg-slate-900/60 backdrop-blur-sm p-4 space-y-3">
|
<div className={cn(
|
||||||
|
"border rounded-xl p-6 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300",
|
||||||
|
isLight ? "bg-indigo-50/30 border-indigo-100" : "bg-slate-900/60 border-white/5 backdrop-blur-sm"
|
||||||
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-slate-300 uppercase tracking-wide mb-1">
|
<h4 className={cn("text-[11px] font-bold uppercase tracking-wider mb-2", isLight ? "text-indigo-600" : "text-slate-400")}>
|
||||||
Answer
|
Answer
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-slate-100 whitespace-pre-wrap">
|
<p className={cn("text-[15px] leading-relaxed whitespace-pre-wrap", isLight ? "text-slate-800" : "text-slate-100")}>
|
||||||
{answer}
|
{answer}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{sources.length > 0 && (
|
{sources.length > 0 && (
|
||||||
<div className="border-t border-white/10 pt-3">
|
<div className={cn("border-t pt-4", isLight ? "border-indigo-100" : "border-white/10")}>
|
||||||
<h5 className="text-xs font-semibold text-slate-300 uppercase tracking-wide mb-1">
|
<h5 className={cn("text-[11px] font-bold uppercase tracking-wider mb-2", isLight ? "text-indigo-600" : "text-slate-400")}>
|
||||||
Sources
|
Sources
|
||||||
</h5>
|
</h5>
|
||||||
<ul className="space-y-1 text-xs text-slate-400">
|
<ul className="space-y-2 text-xs">
|
||||||
{sources.map((source, index) => (
|
{sources.map((source, index) => (
|
||||||
<li key={source.id}>
|
<li key={source.id} className="flex items-center gap-2">
|
||||||
<span className="font-medium text-slate-200">
|
<span className={cn(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 rounded bg-slate-100 text-[10px] font-bold",
|
||||||
|
isLight ? "text-slate-600" : "text-slate-400 bg-slate-800"
|
||||||
|
)}>
|
||||||
{source.type === "FAQ"
|
{source.type === "FAQ"
|
||||||
? "FAQ"
|
? "FAQ"
|
||||||
: "Document"}{" "}
|
: "Document"}
|
||||||
#{index + 1}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-slate-500">
|
<span className={isLight ? "text-slate-500" : "text-slate-400"}>
|
||||||
(similarity:{" "}
|
Source #{index + 1}
|
||||||
{source.similarity.toFixed(2)})
|
</span>
|
||||||
|
<span className="text-slate-400 ml-auto">
|
||||||
|
Similarity: {(source.similarity * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type KnowledgeDocument = {
|
type KnowledgeDocument = {
|
||||||
id: string
|
id: string
|
||||||
@ -18,6 +20,8 @@ type DocumentsSectionProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentsSection({ storeId }: DocumentsSectionProps) {
|
export function DocumentsSection({ storeId }: DocumentsSectionProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
@ -116,14 +120,14 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">
|
<h3 className={cn("text-lg font-semibold", isLight ? "text-slate-900" : "text-white")}>
|
||||||
Documents
|
Documents
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400">
|
<p className={cn("text-xs", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Upload PDFs or DOCX files to power AI answers.
|
Upload PDFs or DOCX files to power AI answers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="inline-flex items-center px-3 py-2 rounded-lg bg-indigo-600/80 hover:bg-indigo-600 text-sm font-medium text-white cursor-pointer transition-colors">
|
<label className="inline-flex items-center px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-sm font-medium text-white cursor-pointer transition-colors shadow-sm">
|
||||||
<span>{isUploading ? "Uploading..." : "Upload document"}</span>
|
<span>{isUploading ? "Uploading..." : "Upload document"}</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -136,60 +140,76 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 bg-red-950/40 border border-red-900/50 rounded-md px-3 py-2">
|
<div className="text-xs text-red-500 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border border-white/5 rounded-xl bg-slate-900/40 backdrop-blur-sm">
|
<div className={cn(
|
||||||
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
"border rounded-xl",
|
||||||
<span className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
isLight ? "border-slate-100 bg-white" : "border-white/5 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"px-4 py-3 border-b flex items-center justify-between",
|
||||||
|
isLight ? "border-slate-100" : "border-white/5"
|
||||||
|
)}>
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs font-semibold uppercase tracking-wide",
|
||||||
|
isLight ? "text-slate-500" : "text-slate-300"
|
||||||
|
)}>
|
||||||
Uploaded documents
|
Uploaded documents
|
||||||
</span>
|
</span>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="text-xs text-slate-400">Loading...</span>
|
<span className="text-xs text-slate-400 animate-pulse">Loading...</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-white/5">
|
<div className={cn(
|
||||||
|
"divide-y",
|
||||||
|
isLight ? "divide-slate-50" : "divide-white/5"
|
||||||
|
)}>
|
||||||
{documents.length === 0 && !isLoading ? (
|
{documents.length === 0 && !isLoading ? (
|
||||||
<div className="px-4 py-6 text-sm text-slate-400 text-center">
|
<div className="px-4 py-10 text-sm text-slate-400 text-center">
|
||||||
No documents yet. Upload your first knowledge base file.
|
No documents yet. Upload your first knowledge base file.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
documents.map((doc) => (
|
documents.map((doc) => (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="px-4 py-3 flex items-center justify-between gap-4 text-sm"
|
className="px-4 py-4 flex items-center justify-between gap-4 text-sm hover:bg-slate-50/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-white">
|
<p className={cn("font-medium truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
{doc.title}
|
{doc.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
{doc.fileName} •{" "}
|
{doc.fileName} •{" "}
|
||||||
{(doc.sizeBytes / 1024).toFixed(1)} KB
|
{(doc.sizeBytes / 1024).toFixed(1)} KB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-right">
|
<div className="flex items-center gap-4 text-xs">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-1 rounded-full border ${
|
className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-1 rounded-full border text-[10px] font-bold uppercase tracking-wider",
|
||||||
doc.status === "READY"
|
doc.status === "READY"
|
||||||
? "border-emerald-500/60 text-emerald-400 bg-emerald-500/10"
|
? "border-emerald-500/20 text-emerald-600 bg-emerald-50"
|
||||||
: doc.status === "PROCESSING"
|
: doc.status === "PROCESSING"
|
||||||
? "border-amber-500/60 text-amber-400 bg-amber-500/10"
|
? "border-amber-500/20 text-amber-600 bg-amber-50"
|
||||||
: doc.status === "FAILED"
|
: doc.status === "FAILED"
|
||||||
? "border-red-500/60 text-red-400 bg-red-500/10"
|
? "border-red-500/20 text-red-600 bg-red-50"
|
||||||
: "border-slate-600 text-slate-300 bg-slate-800/60"
|
: "border-slate-200 text-slate-500 bg-slate-50"
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{doc.status.toLowerCase()}
|
{doc.status.toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleDelete(doc.id)}
|
onClick={() => void handleDelete(doc.id)}
|
||||||
className="px-2 py-1 rounded-md border border-red-500/60 text-red-400 hover:bg-red-500/10 transition-colors"
|
className="p-1.5 rounded-lg border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
title="Delete document"
|
||||||
>
|
>
|
||||||
Delete
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type FaqItem = {
|
type FaqItem = {
|
||||||
id: string
|
id: string
|
||||||
@ -15,6 +17,8 @@ type FaqSectionProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FaqSection({ storeId }: FaqSectionProps) {
|
export function FaqSection({ storeId }: FaqSectionProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
const [faqs, setFaqs] = useState<FaqItem[]>([])
|
const [faqs, setFaqs] = useState<FaqItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -25,28 +29,28 @@ export function FaqSection({ storeId }: FaqSectionProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!storeId) return
|
if (!storeId) return
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/stores/${encodeURIComponent(storeId)}/kb/faqs`,
|
`/api/stores/${encodeURIComponent(storeId)}/kb/faqs`,
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to load FAQs")
|
throw new Error("Failed to load FAQs")
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { faqs: FaqItem[] }
|
||||||
|
setFaqs(json.faqs)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to load FAQs",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as { faqs: FaqItem[] }
|
})()
|
||||||
setFaqs(json.faqs)
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Failed to load FAQs",
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [storeId])
|
}, [storeId])
|
||||||
|
|
||||||
async function handleAddFaq(e: React.FormEvent) {
|
async function handleAddFaq(e: React.FormEvent) {
|
||||||
@ -165,13 +169,18 @@ export function FaqSection({ storeId }: FaqSectionProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">FAQs</h3>
|
<h3 className={cn("text-lg font-semibold", isLight ? "text-slate-900" : "text-white")}>FAQs</h3>
|
||||||
<p className="text-xs text-slate-400">
|
<p className={cn("text-xs", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
Manage frequently asked questions that your AI can use as
|
Manage frequently asked questions that your AI can use as
|
||||||
trusted answers.
|
trusted answers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="inline-flex items-center px-3 py-2 rounded-lg bg-slate-800/80 hover:bg-slate-800 text-xs font-medium text-slate-100 cursor-pointer transition-colors border border-white/10">
|
<label className={cn(
|
||||||
|
"inline-flex items-center px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors border shadow-sm",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||||
|
: "bg-slate-800/80 hover:bg-slate-800 border-white/10 text-slate-100 shadow-xl"
|
||||||
|
)}>
|
||||||
<span>
|
<span>
|
||||||
{isImporting ? "Importing..." : "Import from JSON file"}
|
{isImporting ? "Importing..." : "Import from JSON file"}
|
||||||
</span>
|
</span>
|
||||||
@ -186,70 +195,97 @@ export function FaqSection({ storeId }: FaqSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 bg-red-950/40 border border-red-900/50 rounded-md px-3 py-2">
|
<div className="text-xs text-red-500 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAddFaq}
|
onSubmit={handleAddFaq}
|
||||||
className="border border-white/5 rounded-xl bg-slate-900/40 backdrop-blur-sm p-4 space-y-3"
|
className={cn(
|
||||||
|
"border rounded-xl p-4 space-y-4 transition-all",
|
||||||
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-slate-900/40 border-white/5 backdrop-blur-sm"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
<div>
|
||||||
Question
|
<label className={cn("block text-[11px] font-bold uppercase tracking-wider mb-1.5", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
</label>
|
Question
|
||||||
<input
|
</label>
|
||||||
value={question}
|
<input
|
||||||
onChange={(e) => setQuestion(e.target.value)}
|
value={question}
|
||||||
placeholder="e.g. What is your return policy?"
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg bg-slate-950/60 border border-white/10 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/70 focus:border-indigo-500/70"
|
placeholder="e.g. What is your return policy?"
|
||||||
/>
|
className={cn(
|
||||||
|
"w-full px-3 py-2 rounded-lg border text-sm transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500/50",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-900 placeholder:text-slate-400"
|
||||||
|
: "bg-slate-950/60 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={cn("block text-[11px] font-bold uppercase tracking-wider mb-1.5", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
|
Answer
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={answer}
|
||||||
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
|
placeholder="Provide a clear, friendly answer that can be shown directly to customers."
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 rounded-lg border text-sm transition-all focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500/50 min-h-[100px]",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 text-slate-900 placeholder:text-slate-400"
|
||||||
|
: "bg-slate-950/60 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-end pt-2">
|
||||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
|
||||||
Answer
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={answer}
|
|
||||||
onChange={(e) => setAnswer(e.target.value)}
|
|
||||||
placeholder="Provide a clear, friendly answer that can be shown directly to customers."
|
|
||||||
className="w-full px-3 py-2 rounded-lg bg-slate-950/60 border border-white/10 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/70 focus:border-indigo-500/70 min-h-[80px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="inline-flex items-center px-3 py-2 rounded-lg bg-indigo-600/80 hover:bg-indigo-600 text-xs font-medium text-white disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
className="inline-flex items-center px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-xs font-bold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
{isSaving ? "Saving..." : "Add FAQ"}
|
{isSaving ? "Saving..." : "Add FAQ"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="border border-white/5 rounded-xl bg-slate-900/40 backdrop-blur-sm">
|
<div className={cn(
|
||||||
<div className="px-4 py-3 border-b border-white/5 flex items-center justify-between">
|
"border rounded-xl transition-all",
|
||||||
<span className="text-xs font-medium text-slate-300 uppercase tracking-wide">
|
isLight ? "bg-white border-slate-100 shadow-sm" : "bg-slate-900/40 border-white/5 backdrop-blur-sm"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"px-4 py-3 border-b flex items-center justify-between",
|
||||||
|
isLight ? "border-slate-100" : "border-white/5"
|
||||||
|
)}>
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs font-semibold uppercase tracking-wide",
|
||||||
|
isLight ? "text-slate-500" : "text-slate-300"
|
||||||
|
)}>
|
||||||
Existing FAQs
|
Existing FAQs
|
||||||
</span>
|
</span>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="text-xs text-slate-400">Loading...</span>
|
<span className="text-xs text-slate-400 animate-pulse">Loading...</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-white/5">
|
<div className={cn(
|
||||||
|
"divide-y",
|
||||||
|
isLight ? "divide-slate-50" : "divide-white/5"
|
||||||
|
)}>
|
||||||
{faqs.length === 0 && !isLoading ? (
|
{faqs.length === 0 && !isLoading ? (
|
||||||
<div className="px-4 py-6 text-sm text-slate-400 text-center">
|
<div className="px-4 py-10 text-sm text-slate-400 text-center">
|
||||||
No FAQs yet. Add your first common question above or
|
No FAQs yet. Add your first common question above or
|
||||||
import from JSON.
|
import from JSON.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
faqs.map((faq) => (
|
faqs.map((faq) => (
|
||||||
<div key={faq.id} className="px-4 py-3 text-sm">
|
<div key={faq.id} className="px-4 py-4 text-sm hover:bg-slate-50/50 transition-colors group">
|
||||||
<p className="font-medium text-white">
|
<p className={cn("font-semibold", isLight ? "text-slate-900" : "text-white")}>
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-300">
|
<p className={cn("mt-1.5 text-[13px] leading-relaxed", isLight ? "text-slate-600" : "text-slate-300")}>
|
||||||
{faq.answer}
|
{faq.answer}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user