2025-12-26 13:12:37 +00:00

721 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import axios from 'axios';
import { useRouter } from 'next/navigation';
import React, { useEffect, useMemo, useState } from 'react';
/** ================== Config ================== */
const BACKEND_BASE =
process.env.NEXT_PUBLIC_BACKEND_BASEURL || 'https://ebay.backend.data4autos.com';
// Your GET route that lists from tbl_user_products_mapping
// Example: GET {BACKEND_BASE}/api/user-products-mapping?userid=...&page=1&pageSize=50&search=...
const ENDPOINT_PRODUCTS = `${BACKEND_BASE}/api/motorstate/user-products`;
// Optional: where you queue selected products (same as your other page)
const ENDPOINT_QUEUE = `${BACKEND_BASE}/api/ebay/withdraw-offer`;
/** ================== Types ================== */
type ProductRow = {
trand_id: number;
user_id: string;
id: string | null; // Turn14 item id
sku: string | null;
imgSrc: string | null;
name: string | null;
partNumber: string | null;
category: string | null;
subcategory: string | null;
price: number | string | null; // handle either
inventory: number | string | null;
description: string | null;
offer_status: string | null;
offer_offerId: string | null;
offer_listingId: string | null;
offer_categoryId: string | null;
offer_url: string | null;
offer_error: string | null;
created_at: string;
updated_at: string;
};
type ApiListResp = {
code: 'PRODUCT_MAPPING_LIST';
page: number;
pageSize: number;
total: number;
items: ProductRow[];
};
type Filters = {
inStockOnly: boolean;
priceMin: number;
category: string;
subcategory: string;
offer_status: string;
};
/** ================== Helpers ================== */
const toNumber = (val: unknown): number => {
if (typeof val === 'number') return Number.isFinite(val) ? val : 0;
if (typeof val === 'string') {
const cleaned = val.replace(/[^0-9.\-]/g, '');
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : 0;
}
return 0;
};
const quantityToList = (qty: number) => {
if (qty <= 1) return 1;
if (qty >= 2 && qty <= 8) return qty;
return 8;
};
/** ================== Component ================== */
const ManageAddedProducts: React.FC = () => {
// Session values (only available client-side)
const userId =
typeof window !== 'undefined' ? sessionStorage.getItem('USERID') : null;
const EBAYSTOREID =
typeof window !== 'undefined' ? sessionStorage.getItem('EBAYSTOREID') : null;
const router = useRouter()
// Data state
const [items, setItems] = useState<ProductRow[]>([]);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(48);
const [total, setTotal] = useState<number>(0);
// UI state
const [isScrolled, setIsScrolled] = useState(false);
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState('');
const [toastActive, setToastActive] = useState(false);
// Filters / query
const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<Filters>({
inStockOnly: false,
priceMin: 0,
category: '',
subcategory: '',
offer_status: '',
});
// Selection
const [selected, setSelected] = useState<Record<number, ProductRow>>({});
const [payment, setPayment] = useState<any>(null);
useEffect(() => {
const role = localStorage.getItem("user_role");
const sessionId = localStorage.getItem("payment_session");
// ✅ Admins and Partners can access directly (skip payment check)
if (role === "admin" || role === "partner") {
return;
}
// 🚫 If no payment session, redirect to pricing
if (!sessionId) {
router.push("/pricing");
return;
}
// ✅ Otherwise, check payment details
const fetchPaymentDetails = async () => {
try {
const res: any = await axios.get(
"https://ebay.backend.data4autos.com/api/payment/details",
{ params: { session_id: sessionId } }
);
setPayment(res.data.payment);
} catch (err) {
console.error("Error fetching payment details:", err);
}
};
fetchPaymentDetails();
}, [router]);
/** Scroll shadow effect */
useEffect(() => {
const onScroll = () => setIsScrolled(window.scrollY > 10);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
/** Data loader */
useEffect(() => {
if (!userId) return;
const load = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
userid: userId,
page: String(page),
pageSize: String(pageSize),
});
if (searchText) params.set('search', searchText);
if (filters.category) params.set('category', filters.category);
if (filters.subcategory) params.set('subcategory', filters.subcategory);
if (filters.offer_status) params.set('offer_status', filters.offer_status);
const url = `${ENDPOINT_PRODUCTS}?${params.toString()}`;
const res = await fetch(url, { method: 'GET' });
const data: ApiListResp = await res.json();
setItems(data.items || []);
setTotal(data.total || 0);
setToast(`Loaded ${data.items?.length || 0} items (Total: ${data.total || 0})`);
setTimeout(() => setToast(''), 3000);
} catch (e) {
console.error(e);
setToast('Failed to load products');
setTimeout(() => setToast(''), 3000);
} finally {
setLoading(false);
}
};
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, page, pageSize, searchText, filters.category, filters.subcategory, filters.offer_status]);
/** Dropdown options (use Array.from to avoid TS downlevel iteration issue) */
const categoryOptions = useMemo(() => {
const s = new Set<string>();
items.forEach((i) => i.category && s.add(i.category));
return [''].concat(Array.from(s).sort());
}, [items]);
const subcategoryOptions = useMemo(() => {
const s = new Set<string>();
items.forEach((i) => {
if (!filters.category || i.category === filters.category) {
i.subcategory && s.add(i.subcategory);
}
});
return [''].concat(Array.from(s).sort());
}, [items, filters.category]);
const offerStatusOptions = useMemo(() => {
const s = new Set<string>();
items.forEach((i) => i.offer_status && s.add(i.offer_status));
return [''].concat(Array.from(s).sort());
}, [items]);
/** Client-side filters (in-stock & price floor + search) */
const filteredItems = useMemo(() => {
return items.filter((it) => {
const inv = toNumber(it.inventory);
const price = toNumber(it.price);
if (filters.inStockOnly && inv <= 0) return false;
if (price < filters.priceMin) return false;
if (searchText) {
const hay = `${it.id ?? ''} ${it.sku ?? ''} ${it.name ?? ''} ${it.partNumber ?? ''} ${it.category ?? ''} ${it.subcategory ?? ''}`.toLowerCase();
if (!hay.includes(searchText.toLowerCase())) return false;
}
return true;
});
}, [items, filters.inStockOnly, filters.priceMin, searchText]);
/** Selection helpers */
const selectedCount = useMemo(() => Object.keys(selected).length, [selected]);
const isSelected = (row: ProductRow) => Boolean(selected[row.trand_id]);
const toggleSelect = (row: ProductRow) => {
setSelected((prev) => {
const copy = { ...prev };
if (copy[row.trand_id]) delete copy[row.trand_id];
else copy[row.trand_id] = row;
return copy;
});
};
const toggleSelectAllVisible = () => {
setSelected((prev) => {
const copy = { ...prev };
const allVisibleSelected =
filteredItems.length > 0 &&
filteredItems.every((it) => copy[it.trand_id]);
if (allVisibleSelected) {
filteredItems.forEach((it) => {
if (copy[it.trand_id]) delete copy[it.trand_id];
});
} else {
filteredItems.forEach((it) => (copy[it.trand_id] = it));
}
return copy;
});
};
const clearVisibleSelection = () => {
setSelected((prev) => {
const copy = { ...prev };
filteredItems.forEach((it) => {
if (copy[it.trand_id]) delete copy[it.trand_id];
});
return copy;
});
};
/** Queue selected products (mirrors your other pages payload shape) */
const handleAddSelectedProducts = async () => {
const picked = Object.values(selected);
const queuePayload = picked.map(x => x.offer_offerId)
console.log('Adding products:', queuePayload);
setToast(`Queued ${queuePayload.length} product(s)`);
try {
const res = await fetch(ENDPOINT_QUEUE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userKey: EBAYSTOREID,
products: queuePayload,
userId,
}),
});
const data = await res.json();
console.log('Queue response:', data);
setToastActive(true);
setTimeout(() => setToastActive(false), 2500);
} catch (e) {
console.error(e);
setToast('Failed to queue products');
}
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
/** ================== Render ================== */
return (
<div className="bg-gradient-to-br from-[#00d1ff]/10 via-white to-[#00d1ff]/20 min-h-screen">
{/* Sticky Header */}
<div
className={`sticky top-14 z-10 transition-all duration-300 ${isScrolled
? 'bg-white/95 backdrop-blur-md shadow-lg py-3'
: 'bg-white/80 backdrop-blur-sm py-4'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex flex-col">
<h1 className="text-2xl font-bold bg-[#00d1ff] bg-clip-text text-[#00d1ff]">
Data4Autos Your Listed Products
</h1>
<p className="text-sm text-gray-500 mt-1">
Showing {filteredItems.length} of {items.length} (Total in DB: {total})
</p>
<p className="text-sm font-medium text-[#00d1ff] mt-1">
Selected: <span className="font-semibold">{selectedCount}</span>
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center w-full md:w-auto">
{/* Search */}
<div className="relative flex-grow">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<input
type="text"
value={searchText}
onChange={(e) => {
setPage(1);
setSearchText(e.target.value);
}}
placeholder="Search (sku, name, id)…"
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
{/* In-stock-only pill toggle */}
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer select-none">
<div className="relative">
<input
type="checkbox"
checked={filters.inStockOnly}
onChange={() =>
setFilters((f) => ({ ...f, inStockOnly: !f.inStockOnly }))
}
className="sr-only"
/>
<div
className={`block w-10 h-6 rounded-full transition-colors ${filters.inStockOnly ? 'bg-[#00d1ff]' : 'bg-gray-300'
}`}
></div>
<div
className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${filters.inStockOnly ? 'transform translate-x-4' : ''
}`}
></div>
</div>
In Stock Only
</label>
<button
onClick={handleAddSelectedProducts}
disabled={selectedCount === 0}
className="px-5 py-2.5 bg-[#00d1ff] to-purple-600 text-white font-medium rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-300 transform hover:-translate-y-0.5 disabled:opacity-50 disabled:transform-none disabled:cursor-not-allowed flex items-center gap-2 shadow-md hover:shadow-lg"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
Unlist {selectedCount} Product{selectedCount === 1 ? '' : 's'}
</button>
</div>
</div>
</div>
{/* Global Filters */}
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Category */}
<label className="text-sm text-gray-700 flex items-center">
<span className="mr-2 whitespace-nowrap">Category:</span>
<select
value={filters.category}
onChange={(e) => {
setPage(1);
setFilters((f) => ({
...f,
category: e.target.value,
subcategory: '', // reset when category changes
}));
}}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full"
>
{categoryOptions.map((c) => (
<option key={c} value={c}>
{c || 'All'}
</option>
))}
</select>
</label>
{/* Subcategory */}
<label className="text-sm text-gray-700 flex items-center">
<span className="mr-2 whitespace-nowrap">Subcategory:</span>
<select
value={filters.subcategory}
onChange={(e) => {
setPage(1);
setFilters((f) => ({ ...f, subcategory: e.target.value }));
}}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full"
>
{subcategoryOptions.map((s) => (
<option key={s} value={s}>
{s || 'All'}
</option>
))}
</select>
</label>
{/* Offer Status */}
<label className="text-sm text-gray-700 flex items-center">
<span className="mr-2 whitespace-nowrap">Offer Status:</span>
<select
value={filters.offer_status}
onChange={(e) => {
setPage(1);
setFilters((f) => ({ ...f, offer_status: e.target.value }));
}}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full"
>
{offerStatusOptions.map((s) => (
<option key={s} value={s}>
{s || 'All'}
</option>
))}
</select>
</label>
{/* Price Floor */}
<div className="text-sm text-gray-700">
<div className="mb-1 font-medium">Price Floor</div>
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={5000}
step={5}
value={filters.priceMin}
onChange={(e) =>
setFilters((f) => ({ ...f, priceMin: toNumber(e.target.value) }))
}
className="w-full"
aria-label="Minimum price"
/>
<input
type="number"
min={0}
value={filters.priceMin}
onChange={(e) =>
setFilters((f) => ({ ...f, priceMin: toNumber(e.target.value) }))
}
className="w-24 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="text-xs text-gray-500 mt-1">
Showing items with price ${filters.priceMin}
</div>
</div>
</div>
{/* Pagination Controls */}
<div className="mt-3 flex items-center gap-3 text-sm text-gray-700">
<div>
Page <span className="font-semibold">{page}</span> of{' '}
<span className="font-semibold">{Math.max(1, Math.ceil(total / pageSize))}</span>
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1.5 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
<button
className="px-3 py-1.5 rounded border border-gray-300 hover:bg-gray-50 disabled:opacity-50"
onClick={() => setPage((p) => p + 1)}
disabled={page >= Math.max(1, Math.ceil(total / pageSize)) || loading}
>
Next
</button>
<label className="ml-3">
<span className="mr-2">Page size</span>
<select
className="border rounded px-2 py-1"
value={pageSize}
onChange={(e) => {
setPage(1);
setPageSize(Math.max(1, Math.min(200, Number(e.target.value) || 48)));
}}
>
{[24, 36, 48, 60, 96, 120, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
</div>
</div>
</div>
{/* Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-6 pb-16">
{loading ? (
<div className="py-20 text-center text-gray-500">Loading products</div>
) : filteredItems.length === 0 ? (
<div className="flex items-center justify-center py-24 bg-gradient-to-b from-gray-50 to-gray-100">
<div className="relative text-center bg-white/80 backdrop-blur-md border border-white/40 shadow-2xl rounded-3xl p-10 transition-all duration-500 hover:scale-[1.02]">
{/* Icon Circle */}
<div className="w-20 h-20 mx-auto flex items-center justify-center rounded-full bg-[#00d1ff] shadow-lg">
<svg
className="w-10 h-10 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12h6m2 0a8 8 0 11-16 0 8 8 0 0116 0z"
/>
</svg>
</div>
{/* Text */}
<h3 className="mt-6 text-2xl font-semibold text-gray-800 tracking-tight">
No Products Found
</h3>
<p className="mt-3 text-gray-500 max-w-sm mx-auto">
No products match the current filters. Try adjusting your search or filters to find what you need.
</p>
{/* Button */}
{/* <button className="mt-6 px-6 py-3 bg-[#00d1ff] text-white rounded-xl shadow-md hover:shadow-lg transition-transform duration-300 hover:scale-105">
Reset Filters
</button> */}
{/* Decorative Blur Circle */}
<div className="absolute -top-10 -right-10 w-32 h-32 bg-[#00d1ff] rounded-full blur-3xl"></div>
<div className="absolute -bottom-10 -left-10 w-32 h-32 bg-[#00d1ff] rounded-full blur-3xl"></div>
</div>
</div>
) : (
<>
<div className="mb-3 flex items-center justify-between">
<div className="text-xs text-gray-600">
Showing <span className="font-semibold">{filteredItems.length}</span> of{' '}
<span className="font-semibold">{items.length}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleSelectAllVisible}
className="text-xs px-3 py-1.5 rounded border border-gray-300 hover:bg-gray-50"
>
{filteredItems.length > 0 &&
filteredItems.every((it) => selected[it.trand_id])
? 'Unselect All Visible'
: 'Select All Visible'}
</button>
{Object.values(selected).some((r) =>
filteredItems.find((fi) => fi.trand_id === r.trand_id)
) && (
<button
onClick={clearVisibleSelection}
className="text-xs px-3 py-1.5 rounded border border-gray-300 hover:bg-gray-50"
>
Clear Visible
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filteredItems.map((it) => {
const img =
it.imgSrc ||
'https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png';
const inv = toNumber(it.inventory);
const price = toNumber(it.price);
const willList = quantityToList(inv);
return (
<div key={it.trand_id} className="border border-gray-200 rounded-lg p-3 hover:shadow-sm bg-white">
{/* Select */}
<div className="flex items-center justify-between mb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(selected[it.trand_id])}
onChange={() => toggleSelect(it)}
className="h-4 w-4 rounded border-gray-300 text-[#00d1ff] focus:ring-blue-500"
/>
<span className="text-xs text-gray-600">Select</span>
</label>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-slate-100 text-slate-700">
Will list: {willList}
</span>
</div>
<img
src={img}
alt={it.name || 'Product'}
className="w-full h-24 object-contain mb-2 bg-gray-50 rounded"
/>
<div className="text-sm font-medium line-clamp-2">{it.name || 'Unnamed Product'}</div>
<div className="text-xs text-gray-600 mt-1">
Part #: <span className="font-medium">{it.partNumber || '-'}</span>
</div>
<div className="text-xs text-gray-600">
{it.category || '-'} &gt; {it.subcategory || '-'}
</div>
<div className="text-xs">
Price:{' '}
<span className="font-semibold">
{Number.isFinite(price) && price > 0 ? `$${price.toFixed(2)}` : '-'}
</span>
</div>
<div className="text-xs text-gray-700">
Inventory: <span className="font-semibold">{inv}</span>
</div>
{it.description && (
<p className="mt-2 text-xs text-gray-700 line-clamp-3">{it.description}</p>
)}
{/* Offer / URL badges */}
<div className="mt-2 flex flex-wrap gap-1">
{it.offer_status && (
<span className="text-[10px] px-2 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
{it.offer_status}
</span>
)}
{it.offer_url && (
<a
href={it.offer_url}
target="_blank"
rel="noreferrer"
className="text-[10px] px-2 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
>
Listing
</a>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
{/* Toasts */}
{toast && (
<div className="fixed bottom-6 right-6 bg-gradient-to-r from-slate-800 to-black text-white px-6 py-4 rounded-2xl shadow-2xl z-30 animate-fade-in-up">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
<span className="font-medium">{toast}</span>
</div>
</div>
)}
{toastActive && (
<div className="fixed bottom-6 right-6 bg-gradient-to-r from-green-500 to-emerald-600 text-white px-6 py-4 rounded-2xl shadow-2xl z-30 animate-fade-in-up">
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
<span className="font-medium">Products Unlisted successfully!</span>
</div>
</div>
)}
{/* Tiny animation helper */}
<style jsx global>{`
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate3d(0, 40px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
`}</style>
</div>
);
};
export default ManageAddedProducts;