Add new/preorder/recent filters for product editor, improve data fetching

This commit is contained in:
2026-01-31 13:05:05 -05:00
parent 89d518b57f
commit dd0e989669
7 changed files with 74408 additions and 88 deletions

73544
docs/klaviyoopenapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1254,6 +1254,251 @@ router.get('/search-products', async (req, res) => {
}
});
// Shared SELECT for product queries (matches search-products fields)
const PRODUCT_SELECT = `
SELECT
p.pid,
p.description AS title,
p.notes AS description,
p.itemnumber AS sku,
p.upc AS barcode,
p.harmonized_tariff_code,
pcp.price_each AS price,
p.sellingprice AS regular_price,
CASE
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
END AS cost_price,
s.companyname AS vendor,
sid.supplier_itemnumber AS vendor_reference,
sid.notions_itemnumber AS notions_reference,
sid.supplier_id AS supplier,
sid.notions_case_pack AS case_qty,
pc1.name AS brand,
p.company AS brand_id,
pc2.name AS line,
p.line AS line_id,
pc3.name AS subline,
p.subline AS subline_id,
pc4.name AS artist,
p.artist AS artist_id,
COALESCE(CASE
WHEN sid.supplier_id = 92 THEN sid.notions_qty_per_unit
ELSE sid.supplier_qty_per_unit
END, sid.notions_qty_per_unit) AS moq,
p.weight,
p.length,
p.width,
p.height,
p.country_of_origin,
ci.totalsold AS total_sold,
p.datein AS first_received,
pls.date_sold AS date_last_sold,
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
CAST(p.size_cat AS CHAR) AS size_cat,
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions
FROM products p
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid
LEFT JOIN product_categories pc1 ON p.company = pc1.cat_id
LEFT JOIN product_categories pc2 ON p.line = pc2.cat_id
LEFT JOIN product_categories pc3 ON p.subline = pc3.cat_id
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
LEFT JOIN current_inventory ci ON p.pid = ci.pid`;
// Load products for a specific line (company + line + optional subline)
router.get('/line-products', async (req, res) => {
const { company, line, subline } = req.query;
if (!company || !line) {
return res.status(400).json({ error: 'company and line are required' });
}
try {
const { connection } = await getDbConnection();
let where = 'WHERE p.company = ? AND p.line = ?';
const params = [Number(company), Number(line)];
if (subline) {
where += ' AND p.subline = ?';
params.push(Number(subline));
}
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY p.description`;
const [results] = await connection.query(query, params);
res.json(results);
} catch (error) {
console.error('Error loading line products:', error);
res.status(500).json({ error: 'Failed to load line products' });
}
});
// Load new products (last 45 days by release date, excluding preorders)
router.get('/new-products', async (req, res) => {
try {
const { connection } = await getDbConnection();
const query = `${PRODUCT_SELECT}
LEFT JOIN shop_inventory si2 ON p.pid = si2.pid AND si2.store = 0
WHERE DATEDIFF(NOW(), p.date_ol) <= 45
AND p.notnew = 0
AND (si2.all IS NULL OR si2.all != 2)
GROUP BY p.pid
ORDER BY IF(p.date_ol != '0000-00-00', p.date_ol, p.date_created) DESC`;
const [results] = await connection.query(query);
res.json(results);
} catch (error) {
console.error('Error loading new products:', error);
res.status(500).json({ error: 'Failed to load new products' });
}
});
// Load preorder products
router.get('/preorder-products', async (req, res) => {
try {
const { connection } = await getDbConnection();
const query = `${PRODUCT_SELECT}
LEFT JOIN shop_inventory si2 ON p.pid = si2.pid AND si2.store = 0
WHERE si2.all = 2
GROUP BY p.pid
ORDER BY IF(p.date_ol != '0000-00-00', p.date_ol, p.date_created) DESC`;
const [results] = await connection.query(query);
res.json(results);
} catch (error) {
console.error('Error loading preorder products:', error);
res.status(500).json({ error: 'Failed to load preorder products' });
}
});
// Load hidden recently-created products from local PG, enriched from MySQL
router.get('/hidden-new-products', async (req, res) => {
try {
const pool = req.app.locals.pool;
const pgResult = await pool.query(
`SELECT pid FROM products WHERE visible = false AND created_at > NOW() - INTERVAL '90 days' ORDER BY created_at DESC LIMIT 500`
);
const pids = pgResult.rows.map(r => r.pid);
if (pids.length === 0) return res.json([]);
const { connection } = await getDbConnection();
const placeholders = pids.map(() => '?').join(',');
const query = `${PRODUCT_SELECT} WHERE p.pid IN (${placeholders}) GROUP BY p.pid ORDER BY FIELD(p.pid, ${placeholders})`;
const [results] = await connection.query(query, [...pids, ...pids]);
res.json(results);
} catch (error) {
console.error('Error loading hidden new products:', error);
res.status(500).json({ error: 'Failed to load hidden new products' });
}
});
// Load landing page extras (featured lines) for new/preorder pages
router.get('/landing-extras', async (req, res) => {
const { catId, sid } = req.query;
if (!catId) {
return res.status(400).json({ error: 'catId is required' });
}
try {
const { connection } = await getDbConnection();
const [results] = await connection.query(
`SELECT extra_id, image, extra_cat_id, path, name, top_text, is_new
FROM product_category_landing_extras
WHERE cat_id = ? AND sid = ? AND section_cat_id = 0 AND hidden = 0
ORDER BY \`order\` DESC, name ASC`,
[Number(catId), Number(sid) || 0]
);
res.json(results);
} catch (error) {
console.error('Error loading landing extras:', error);
res.status(500).json({ error: 'Failed to load landing extras' });
}
});
// Load products by shop path (resolves category names to IDs)
router.get('/path-products', async (req, res) => {
res.set('Cache-Control', 'no-store');
const { path: shopPath } = req.query;
if (!shopPath) {
return res.status(400).json({ error: 'path is required' });
}
try {
const { connection } = await getDbConnection();
// Strip common URL prefixes (full URLs, /shop/, leading slash)
const cleanPath = String(shopPath)
.replace(/^https?:\/\/[^/]+/, '')
.replace(/^\/shop\//, '/')
.replace(/^\//, '');
const parts = cleanPath.split('/');
const filters = {};
for (let i = 0; i < parts.length - 1; i += 2) {
filters[parts[i]] = decodeURIComponent(parts[i + 1]).replace(/_/g, ' ');
}
if (Object.keys(filters).length === 0) {
return res.status(400).json({ error: 'No valid filters found in path' });
}
// Resolve category names to IDs (order matters: company -> line -> subline)
const typeMap = { company: 1, line: 2, subline: 3, section: 10, cat: 11, subcat: 12, subsubcat: 13 };
const resolvedIds = {};
const resolveOrder = ['company', 'line', 'subline', 'section', 'cat', 'subcat', 'subsubcat'];
for (const key of resolveOrder) {
const value = filters[key];
if (!value) continue;
const type = typeMap[key];
if (!type) continue;
const types = key === 'cat' ? [11, 20] : key === 'subcat' ? [12, 21] : [type];
// For line/subline, filter by parent (master_cat_id) to disambiguate
let parentFilter = '';
const qParams = [value];
if (key === 'line' && resolvedIds.company != null) {
parentFilter = ' AND master_cat_id = ?';
qParams.push(resolvedIds.company);
} else if (key === 'subline' && resolvedIds.line != null) {
parentFilter = ' AND master_cat_id = ?';
qParams.push(resolvedIds.line);
}
const [rows] = await connection.query(
`SELECT cat_id FROM product_categories WHERE LOWER(name) = LOWER(?) AND type IN (${types.join(',')})${parentFilter} LIMIT 1`,
qParams
);
if (rows.length > 0) {
resolvedIds[key] = rows[0].cat_id;
} else {
return res.json([]);
}
}
// Build WHERE using resolved IDs
const whereParts = [];
const params = [];
const directFields = { company: 'p.company', line: 'p.line', subline: 'p.subline' };
for (const [key, catId] of Object.entries(resolvedIds)) {
if (directFields[key]) {
whereParts.push(`${directFields[key]} = ?`);
params.push(catId);
} else {
whereParts.push('EXISTS (SELECT 1 FROM product_category_index pci2 WHERE pci2.pid = p.pid AND pci2.cat_id = ?)');
params.push(catId);
}
}
if (whereParts.length === 0) {
return res.status(400).json({ error: 'No valid filters found in path' });
}
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY p.description`;
const [results] = await connection.query(query, params);
res.json(results);
} catch (error) {
console.error('Error loading path products:', error);
res.status(500).json({ error: 'Failed to load products by path' });
}
});
// Get product images for a given PID from production DB
router.get('/product-images/:pid', async (req, res) => {
const pid = parseInt(req.params.pid, 10);

View File

@@ -47,14 +47,16 @@ import type { ProductImage } from "./types";
// ── Helper: get best image URL ─────────────────────────────────────────
export function getImageSrc(img: ProductImage): string | null {
export function getImageSrc(img: ProductImage, preferLarge = false): string | null {
if (img.imageUrl) return img.imageUrl;
if (preferLarge) {
return (
img.sizes["600x600"]?.url ??
img.sizes["500x500"]?.url ??
null
);
}
return (
img.sizes["300x300"]?.url ??
img.sizes["l"]?.url ??
img.sizes["o"]?.url ??
null
);
}
@@ -456,13 +458,7 @@ export function ImageManager({
<DialogTitle className="sr-only">Product image preview</DialogTitle>
{zoomImage && (
<img
src={
zoomImage.imageUrl ??
zoomImage.sizes["o"]?.url ??
zoomImage.sizes["600x600"]?.url ??
getImageSrc(zoomImage) ??
""
}
src={getImageSrc(zoomImage, true) ?? ""}
alt={`Image ${zoomImage.iid}`}
className="w-full h-auto object-contain max-h-[80vh] rounded"
/>
@@ -472,3 +468,71 @@ export function ImageManager({
</div>
);
}
// ── Mini Image Preview (for minimal layout) ─────────────────────────────
export function MiniImagePreview({
images,
isLoading,
}: {
images: ProductImage[];
isLoading: boolean;
}) {
const [zoomImage, setZoomImage] = useState<ProductImage | null>(null);
// Match the full ImageManager grid width (CELL * 3 + GAP * 2)
// Match the main image cell size (CELL * 2 + GAP)
const SIZE = 206;
if (isLoading) {
return (
<div className="flex items-center justify-center" style={{ width: SIZE, height: SIZE }}>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
const first = images[0];
if (!first) return null;
const src = getImageSrc(first);
if (!src) return null;
const extra = images.length - 1;
return (
<>
<div
className="relative rounded-lg border bg-white cursor-pointer shrink-0"
style={{ width: SIZE, height: SIZE }}
onClick={() => setZoomImage(first)}
>
<img
src={src}
alt="Product"
className="w-full h-full object-cover rounded-lg"
/>
{extra > 0 && (
<Badge
variant="secondary"
className="absolute -bottom-1.5 -right-1.5 text-sm px-1.5 py-0 leading-relaxed"
>
+{extra}
</Badge>
)}
</div>
<Dialog open={!!zoomImage} onOpenChange={(open) => !open && setZoomImage(null)}>
<DialogContent className="max-w-3xl p-2">
<DialogTitle className="sr-only">Product image preview</DialogTitle>
{zoomImage && (
<img
src={getImageSrc(zoomImage, true) ?? ""}
alt={`Image ${zoomImage.iid}`}
className="w-full h-auto object-contain max-h-[80vh] rounded"
/>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -6,14 +6,13 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
import { EditableMultiSelect } from "./EditableMultiSelect";
import { ImageManager } from "./ImageManager";
import { ImageManager, MiniImagePreview } from "./ImageManager";
import type {
SearchProduct,
FieldOptions,
@@ -23,6 +22,38 @@ import type {
ProductFormValues,
} from "./types";
// Simple in-memory cache for lines/sublines to avoid duplicate requests across forms
const linesCache = new Map<string, Promise<LineOption[]>>();
const sublinesCache = new Map<string, Promise<LineOption[]>>();
function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = linesCache.get(companyId);
if (cached) return cached;
const p = axios
.get(`/api/import/product-lines/${companyId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
linesCache.delete(companyId);
return [] as LineOption[];
});
linesCache.set(companyId, p);
return p;
}
function fetchSublinesCached(lineId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = sublinesCache.get(lineId);
if (cached) return cached;
const p = axios
.get(`/api/import/sublines/${lineId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
sublinesCache.delete(lineId);
return [] as LineOption[];
});
sublinesCache.set(lineId, p);
return p;
}
// --- Field configuration types ---
type FieldType = "input" | "combobox" | "multiselect" | "textarea";
@@ -54,9 +85,9 @@ interface FieldGroup {
// --- Layout modes ---
type LayoutMode = "full" | "minimal" | "shop" | "backend";
export type LayoutMode = "full" | "minimal" | "shop" | "backend";
const LAYOUT_ICONS: { mode: LayoutMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
export const LAYOUT_ICONS: { mode: LayoutMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ mode: "full", label: "Full", icon: Maximize2 },
{ mode: "minimal", label: "Minimal", icon: Minus },
{ mode: "shop", label: "Shop", icon: Store },
@@ -142,7 +173,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
minimal: {
sidebarGroups: 3,
imageRows: 2.2,
descriptionRows: 6,
descriptionRows: 5,
groups: [
{ cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
{ label: "Description", cols: 1, fields: [F.description] },
@@ -153,13 +184,14 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
export function ProductEditForm({
product,
fieldOptions,
layoutMode,
onClose,
}: {
product: SearchProduct;
fieldOptions: FieldOptions;
layoutMode: LayoutMode;
onClose: () => void;
}) {
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
@@ -217,20 +249,22 @@ export function ProductEditForm({
originalValuesRef.current = { ...formValues };
reset(formValues);
// Fetch images
// Fetch images and categories with abort support
const controller = new AbortController();
const { signal } = controller;
setIsLoadingImages(true);
axios
.get(`/api/import/product-images/${product.pid}`)
.get(`/api/import/product-images/${product.pid}`, { signal })
.then((res) => {
setProductImages(res.data);
originalImagesRef.current = res.data;
})
.catch(() => toast.error("Failed to load product images"))
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
.finally(() => setIsLoadingImages(false));
// Fetch product categories (categories, themes, colors)
axios
.get(`/api/import/product-categories/${product.pid}`)
.get(`/api/import/product-categories/${product.pid}`, { signal })
.then((res) => {
const cats: string[] = [];
const themes: string[] = [];
@@ -250,30 +284,30 @@ export function ProductEditForm({
.catch(() => {
// Non-critical — just leave arrays empty
});
return () => controller.abort();
}, [product, reset]);
// Load lines when company changes
// Load lines when company changes (cached across forms)
useEffect(() => {
if (!watchCompany) {
setLineOptions([]);
return;
}
axios
.get(`/api/import/product-lines/${watchCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]));
const controller = new AbortController();
fetchLinesCached(watchCompany, controller.signal).then(setLineOptions);
return () => controller.abort();
}, [watchCompany]);
// Load sublines when line changes
// Load sublines when line changes (cached across forms)
useEffect(() => {
if (!watchLine) {
setSublineOptions([]);
return;
}
axios
.get(`/api/import/sublines/${watchLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]));
const controller = new AbortController();
fetchSublinesCached(watchLine, controller.signal).then(setSublineOptions);
return () => controller.abort();
}, [watchLine]);
const computeImageChanges = useCallback((): ImageChanges | null => {
@@ -462,29 +496,6 @@ export function ProductEditForm({
return (
<Card>
<CardHeader>
{/* Layout toggle */}
<div className="flex items-center justify-end mb-3">
<ToggleGroup
type="single"
value={layoutMode}
onValueChange={(v) => { if (v) setLayoutMode(v as LayoutMode); }}
>
{LAYOUT_ICONS.map(({ mode, label, icon: Icon }) => (
<Tooltip key={mode}>
<TooltipTrigger asChild>
<ToggleGroupItem value={mode} size="sm" variant="outline">
<Icon className="h-4 w-4" />
<span className="text-xs">{label}</span>
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
<Button variant="secondary" size="icon" onClick={onClose} className="ml-4">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 flex items-center gap-1">
<div className="flex-1 min-w-0">
@@ -505,18 +516,33 @@ export function ProductEditForm({
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
asChild
>
<a
href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors px-2"
>
<ExternalLink className="h-5 w-5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">View in Backend</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-end mb-3 ml-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Hide this product</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap gap-2 items-center justify-start">
<Badge
@@ -558,12 +584,16 @@ export function ProductEditForm({
{/* Images + sidebar fields */}
<div className="flex gap-2">
<div className="shrink-0">
{layoutMode === "minimal" ? (
<MiniImagePreview images={productImages} isLoading={isLoadingImages} />
) : (
<ImageManager
images={productImages}
setImages={setProductImages}
isLoading={isLoadingImages}
maxRows={MODE_LAYOUTS[layoutMode].imageRows}
/>
)}
</div>
{MODE_LAYOUTS[layoutMode].sidebarGroups > 0 && (
<div className="flex-1 min-w-0 space-y-1">

View File

@@ -57,6 +57,15 @@ export interface LineOption {
value: string;
}
export interface LandingExtra {
extra_id: number;
name: string;
image: string;
extra_cat_id: number;
path: string;
top_text: string;
}
export interface ProductImage {
iid: number | string; // number for existing, string like "new-0" for added
order: number;

View File

@@ -1,15 +1,76 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import axios from "axios";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
PaginationEllipsis,
} from "@/components/ui/pagination";
import { ProductSearch } from "@/components/product-editor/ProductSearch";
import { ProductEditForm } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions } from "@/components/product-editor/types";
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
const PER_PAGE = 20;
const PROD_IMG_HOST = "https://sbing.com";
/** Strip all HTML except <b>, </b>, and <br> tags */
function sanitizeHtml(html: string): string {
return html.replace(/<\/?(?!b>|br\s*\/?>)[^>]*>/gi, "");
}
/** Strip all HTML tags for use in plain text contexts */
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "");
}
export default function ProductEditor() {
const [selectedProduct, setSelectedProduct] = useState<SearchProduct | null>(null);
const [allProducts, setAllProducts] = useState<SearchProduct[]>([]);
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
const [page, _setPage] = useState(1);
const topRef = useRef<HTMLDivElement>(null);
const setPage = useCallback((v: number | ((p: number) => number)) => {
_setPage(v);
setTimeout(() => topRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
}, []);
const [activeTab, setActiveTab] = useState("new");
const [loadedTab, setLoadedTab] = useState<string | null>(null);
// Line picker state
const [lineCompany, setLineCompany] = useState<string>("");
const [lineLine, setLineLine] = useState<string>("");
const [lineSubline, setLineSubline] = useState<string>("");
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [isLoadingLines, setIsLoadingLines] = useState(false);
const [isLoadingSublines, setIsLoadingSublines] = useState(false);
// Landing extras state
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
// Abort controller for cancelling in-flight product requests
const abortRef = useRef<AbortController | null>(null);
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
const products = useMemo(
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
[allProducts, page]
);
useEffect(() => {
axios
@@ -20,8 +81,253 @@ export default function ProductEditor() {
toast.error("Failed to load field options");
})
.finally(() => setIsLoadingOptions(false));
// Auto-load the default tab
handleTabChange("new");
}, []);
// Load lines when company changes
useEffect(() => {
setLineLine("");
setLineSubline("");
setLineOptions([]);
setSublineOptions([]);
if (!lineCompany) return;
setIsLoadingLines(true);
axios
.get(`/api/import/product-lines/${lineCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]))
.finally(() => setIsLoadingLines(false));
}, [lineCompany]);
// Load sublines when line changes
useEffect(() => {
setLineSubline("");
setSublineOptions([]);
if (!lineLine) return;
setIsLoadingSublines(true);
axios
.get(`/api/import/sublines/${lineLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]))
.finally(() => setIsLoadingSublines(false));
}, [lineLine]);
const handleSearchSelect = useCallback((product: SearchProduct) => {
setAllProducts((prev) => {
if (prev.some((p) => p.pid === product.pid)) return prev;
return [product, ...prev];
});
setPage(1);
}, []);
const handleRemoveProduct = useCallback((pid: number) => {
setAllProducts((prev) => prev.filter((p) => p.pid !== pid));
}, []);
const loadFeedProducts = useCallback(async (endpoint: string, label: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setAllProducts([]);
setIsLoadingProducts(true);
try {
const res = await axios.get(`/api/import/${endpoint}`, { signal: controller.signal });
setAllProducts(res.data);
setPage(1);
toast.success(`Loaded ${res.data.length} ${label} products`);
} catch (e) {
if (!axios.isCancel(e)) toast.error(`Failed to load ${label} products`);
} finally {
setIsLoadingProducts(false);
}
}, []);
const loadLandingExtras = useCallback(async (catId: number, tabKey: string) => {
if (landingExtras[tabKey]) return;
setIsLoadingExtras(true);
try {
const res = await axios.get("/api/import/landing-extras", {
params: { catId, sid: 0 },
});
setLandingExtras((prev) => ({ ...prev, [tabKey]: res.data }));
} catch {
console.error("Failed to load landing extras");
} finally {
setIsLoadingExtras(false);
}
}, [landingExtras]);
const handleLandingClick = useCallback(async (extra: LandingExtra) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setActiveLandingItem(extra.path);
setAllProducts([]);
setIsLoadingProducts(true);
try {
const res = await axios.get("/api/import/path-products", {
params: { path: extra.path },
signal: controller.signal,
});
setAllProducts(res.data);
setPage(1);
toast.success(`Loaded ${res.data.length} products for ${stripHtml(extra.name)}`);
} catch (e) {
if (!axios.isCancel(e)) toast.error("Failed to load products for " + stripHtml(extra.name));
} finally {
setIsLoadingProducts(false);
setActiveLandingItem(null);
}
}, []);
// Auto-load when switching tabs
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
if (tab === "new" && loadedTab !== "new") {
setLoadedTab("new");
loadFeedProducts("new-products", "new");
loadLandingExtras(-2, "new");
} else if (tab === "preorder" && loadedTab !== "preorder") {
setLoadedTab("preorder");
loadFeedProducts("preorder-products", "pre-order");
loadLandingExtras(-16, "preorder");
} else if (tab === "hidden" && loadedTab !== "hidden") {
setLoadedTab("hidden");
loadFeedProducts("hidden-new-products", "hidden");
}
}, [loadedTab, loadFeedProducts, loadLandingExtras]);
const loadLineProducts = useCallback(async () => {
if (!lineCompany || !lineLine) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setAllProducts([]);
setIsLoadingProducts(true);
try {
const params: Record<string, string> = { company: lineCompany, line: lineLine };
if (lineSubline) params.subline = lineSubline;
const res = await axios.get("/api/import/line-products", { params, signal: controller.signal });
setAllProducts(res.data);
setPage(1);
toast.success(`Loaded ${res.data.length} products`);
} catch (e) {
if (!axios.isCancel(e)) toast.error("Failed to load line products");
} finally {
setIsLoadingProducts(false);
}
}, [lineCompany, lineLine, lineSubline]);
const renderLandingExtras = (tabKey: string) => {
const extras = landingExtras[tabKey];
if (!extras || extras.length === 0) return null;
return (
<div className="mb-4">
<div className="flex gap-3 overflow-x-auto pb-2 items-start">
{extras.map((extra) => (
<button
key={extra.extra_id}
onClick={() => handleLandingClick(extra)}
disabled={activeLandingItem === extra.path}
className="flex-shrink-0 group relative w-28 text-left"
>
<div className="aspect-square w-full overflow-hidden rounded-lg border bg-card hover:bg-accent transition-colors relative">
{extra.image && (
<img
src={extra.image.startsWith("/") ? PROD_IMG_HOST + extra.image : extra.image}
alt={stripHtml(extra.name)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
/>
)}
{activeLandingItem === extra.path && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
<div className="pt-1 text-center">
{(() => {
const parts = extra.name.split(/<br\s*\/?>/i).map(stripHtml);
return (
<div className="text-xs leading-snug">
{parts[0] && <span className="font-semibold">{parts[0]}</span>}
{parts[1] && <><br /><span className="font-normal">{parts[1]}</span></>}
</div>
);
})()}
{extra.top_text && (
<div
className="text-[10px] text-muted-foreground mt-0.5"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(extra.top_text) }}
/>
)}
</div>
</button>
))}
</div>
</div>
);
};
const renderPagination = () => {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages: (number | "ellipsis")[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push("ellipsis");
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
pages.push(i);
}
if (page < totalPages - 2) pages.push("ellipsis");
pages.push(totalPages);
}
return pages;
};
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`e${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
onClick={() => setPage(p)}
className="cursor-pointer"
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
};
if (isLoadingOptions) {
return (
<div className="flex items-center justify-center h-64">
@@ -32,24 +338,146 @@ export default function ProductEditor() {
return (
<div className="container mx-auto py-6 max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Product Editor</h1>
<p className="text-muted-foreground">
Search for a product and edit its fields. Only changed fields will be
submitted.
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Layout: </span>
<ToggleGroup
type="single"
value={layoutMode}
onValueChange={(v) => { if (v) setLayoutMode(v as LayoutMode); }}
>
{LAYOUT_ICONS.map(({ mode, label, icon: Icon }) => (
<Tooltip key={mode}>
<TooltipTrigger asChild>
<ToggleGroupItem value={mode} size="sm" variant="outline">
<Icon className="h-4 w-4" />
<span className="text-xs">{label}</span>
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
</div>
</div>
<ProductSearch onSelect={setSelectedProduct} />
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="new">New</TabsTrigger>
<TabsTrigger value="preorder">Pre-Order</TabsTrigger>
<TabsTrigger value="hidden">Hidden (New)</TabsTrigger>
<TabsTrigger value="by-line">By Line</TabsTrigger>
<TabsTrigger value="search">Search</TabsTrigger>
</TabsList>
{selectedProduct && fieldOptions && (
<ProductEditForm
key={selectedProduct.pid}
product={selectedProduct}
fieldOptions={fieldOptions}
onClose={() => setSelectedProduct(null)}
/>
<TabsContent value="search" className="mt-4">
<ProductSearch onSelect={handleSearchSelect} />
</TabsContent>
<TabsContent value="new" className="mt-4">
{isLoadingExtras && !landingExtras["new"] && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<Loader2 className="h-4 w-4 animate-spin" />
Loading featured lines...
</div>
)}
{renderLandingExtras("new")}
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading new products...
</div>
)}
</TabsContent>
<TabsContent value="preorder" className="mt-4">
{isLoadingExtras && !landingExtras["preorder"] && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-3">
<Loader2 className="h-4 w-4 animate-spin" />
Loading featured lines...
</div>
)}
{renderLandingExtras("preorder")}
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading pre-order products...
</div>
)}
</TabsContent>
<TabsContent value="hidden" className="mt-4">
{isLoadingProducts && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading hidden recently-created products...
</div>
)}
</TabsContent>
<TabsContent value="by-line" className="mt-4">
<div className="flex items-center gap-3">
<Select value={lineCompany} onValueChange={setLineCompany}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Select company..." />
</SelectTrigger>
<SelectContent>
{fieldOptions?.companies.map((c: FieldOption) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={lineLine} onValueChange={setLineLine} disabled={!lineCompany || isLoadingLines}>
<SelectTrigger className="w-52">
<SelectValue placeholder={isLoadingLines ? "Loading..." : "Select line..."} />
</SelectTrigger>
<SelectContent>
{lineOptions.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
{sublineOptions.length > 0 && (
<Select value={lineSubline} onValueChange={setLineSubline} disabled={isLoadingSublines}>
<SelectTrigger className="w-52">
<SelectValue placeholder={isLoadingSublines ? "Loading..." : "All sublines"} />
</SelectTrigger>
<SelectContent>
{sublineOptions.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button onClick={loadLineProducts} disabled={!lineLine || isLoadingProducts}>
{isLoadingProducts && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Load
</Button>
</div>
</TabsContent>
</Tabs>
<div ref={topRef} />
{renderPagination()}
{products.length > 0 && fieldOptions && (
<div className="space-y-4">
{products.map((product) => (
<ProductEditForm
key={product.pid}
product={product}
fieldOptions={fieldOptions}
layoutMode={layoutMode}
onClose={() => handleRemoveProduct(product.pid)}
/>
))}
</div>
)}
{renderPagination()}
</div>
);
}

File diff suppressed because one or more lines are too long