Add new/preorder/recent filters for product editor, improve data fetching
This commit is contained in:
73544
docs/klaviyoopenapi.json
Normal file
73544
docs/klaviyoopenapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
// Get product images for a given PID from production DB
|
||||||
router.get('/product-images/:pid', async (req, res) => {
|
router.get('/product-images/:pid', async (req, res) => {
|
||||||
const pid = parseInt(req.params.pid, 10);
|
const pid = parseInt(req.params.pid, 10);
|
||||||
|
|||||||
@@ -47,14 +47,16 @@ import type { ProductImage } from "./types";
|
|||||||
|
|
||||||
// ── Helper: get best image URL ─────────────────────────────────────────
|
// ── 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 (img.imageUrl) return img.imageUrl;
|
||||||
|
if (preferLarge) {
|
||||||
return (
|
return (
|
||||||
img.sizes["600x600"]?.url ??
|
img.sizes["600x600"]?.url ??
|
||||||
img.sizes["500x500"]?.url ??
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
img.sizes["300x300"]?.url ??
|
img.sizes["300x300"]?.url ??
|
||||||
img.sizes["l"]?.url ??
|
|
||||||
img.sizes["o"]?.url ??
|
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -456,13 +458,7 @@ export function ImageManager({
|
|||||||
<DialogTitle className="sr-only">Product image preview</DialogTitle>
|
<DialogTitle className="sr-only">Product image preview</DialogTitle>
|
||||||
{zoomImage && (
|
{zoomImage && (
|
||||||
<img
|
<img
|
||||||
src={
|
src={getImageSrc(zoomImage, true) ?? ""}
|
||||||
zoomImage.imageUrl ??
|
|
||||||
zoomImage.sizes["o"]?.url ??
|
|
||||||
zoomImage.sizes["600x600"]?.url ??
|
|
||||||
getImageSrc(zoomImage) ??
|
|
||||||
""
|
|
||||||
}
|
|
||||||
alt={`Image ${zoomImage.iid}`}
|
alt={`Image ${zoomImage.iid}`}
|
||||||
className="w-full h-auto object-contain max-h-[80vh] rounded"
|
className="w-full h-auto object-contain max-h-[80vh] rounded"
|
||||||
/>
|
/>
|
||||||
@@ -472,3 +468,71 @@ export function ImageManager({
|
|||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
|
import { Loader2, X, Copy, Maximize2, Minus, Store, Terminal, ExternalLink } from "lucide-react";
|
||||||
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
import { submitProductEdit, type ImageChanges } from "@/services/productEditor";
|
||||||
import { EditableComboboxField } from "./EditableComboboxField";
|
import { EditableComboboxField } from "./EditableComboboxField";
|
||||||
import { EditableInput } from "./EditableInput";
|
import { EditableInput } from "./EditableInput";
|
||||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||||
import { ImageManager } from "./ImageManager";
|
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||||
import type {
|
import type {
|
||||||
SearchProduct,
|
SearchProduct,
|
||||||
FieldOptions,
|
FieldOptions,
|
||||||
@@ -23,6 +22,38 @@ import type {
|
|||||||
ProductFormValues,
|
ProductFormValues,
|
||||||
} from "./types";
|
} 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 ---
|
// --- Field configuration types ---
|
||||||
|
|
||||||
type FieldType = "input" | "combobox" | "multiselect" | "textarea";
|
type FieldType = "input" | "combobox" | "multiselect" | "textarea";
|
||||||
@@ -54,9 +85,9 @@ interface FieldGroup {
|
|||||||
|
|
||||||
// --- Layout modes ---
|
// --- 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: "full", label: "Full", icon: Maximize2 },
|
||||||
{ mode: "minimal", label: "Minimal", icon: Minus },
|
{ mode: "minimal", label: "Minimal", icon: Minus },
|
||||||
{ mode: "shop", label: "Shop", icon: Store },
|
{ mode: "shop", label: "Shop", icon: Store },
|
||||||
@@ -142,7 +173,7 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
minimal: {
|
minimal: {
|
||||||
sidebarGroups: 3,
|
sidebarGroups: 3,
|
||||||
imageRows: 2.2,
|
imageRows: 2.2,
|
||||||
descriptionRows: 6,
|
descriptionRows: 5,
|
||||||
groups: [
|
groups: [
|
||||||
{ cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
{ cols: 2, fields: [F.company, F.msrp, F.line, F.subline] },
|
||||||
{ label: "Description", cols: 1, fields: [F.description] },
|
{ label: "Description", cols: 1, fields: [F.description] },
|
||||||
@@ -153,13 +184,14 @@ const MODE_LAYOUTS: Record<LayoutMode, ModeLayout> = {
|
|||||||
export function ProductEditForm({
|
export function ProductEditForm({
|
||||||
product,
|
product,
|
||||||
fieldOptions,
|
fieldOptions,
|
||||||
|
layoutMode,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
product: SearchProduct;
|
product: SearchProduct;
|
||||||
fieldOptions: FieldOptions;
|
fieldOptions: FieldOptions;
|
||||||
|
layoutMode: LayoutMode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("full");
|
|
||||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||||
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||||
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
||||||
@@ -217,20 +249,22 @@ export function ProductEditForm({
|
|||||||
originalValuesRef.current = { ...formValues };
|
originalValuesRef.current = { ...formValues };
|
||||||
reset(formValues);
|
reset(formValues);
|
||||||
|
|
||||||
// Fetch images
|
// Fetch images and categories with abort support
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
setIsLoadingImages(true);
|
setIsLoadingImages(true);
|
||||||
axios
|
axios
|
||||||
.get(`/api/import/product-images/${product.pid}`)
|
.get(`/api/import/product-images/${product.pid}`, { signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setProductImages(res.data);
|
setProductImages(res.data);
|
||||||
originalImagesRef.current = 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));
|
.finally(() => setIsLoadingImages(false));
|
||||||
|
|
||||||
// Fetch product categories (categories, themes, colors)
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/import/product-categories/${product.pid}`)
|
.get(`/api/import/product-categories/${product.pid}`, { signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const cats: string[] = [];
|
const cats: string[] = [];
|
||||||
const themes: string[] = [];
|
const themes: string[] = [];
|
||||||
@@ -250,30 +284,30 @@ export function ProductEditForm({
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Non-critical — just leave arrays empty
|
// Non-critical — just leave arrays empty
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
}, [product, reset]);
|
}, [product, reset]);
|
||||||
|
|
||||||
// Load lines when company changes
|
// Load lines when company changes (cached across forms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!watchCompany) {
|
if (!watchCompany) {
|
||||||
setLineOptions([]);
|
setLineOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
axios
|
const controller = new AbortController();
|
||||||
.get(`/api/import/product-lines/${watchCompany}`)
|
fetchLinesCached(watchCompany, controller.signal).then(setLineOptions);
|
||||||
.then((res) => setLineOptions(res.data))
|
return () => controller.abort();
|
||||||
.catch(() => setLineOptions([]));
|
|
||||||
}, [watchCompany]);
|
}, [watchCompany]);
|
||||||
|
|
||||||
// Load sublines when line changes
|
// Load sublines when line changes (cached across forms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!watchLine) {
|
if (!watchLine) {
|
||||||
setSublineOptions([]);
|
setSublineOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
axios
|
const controller = new AbortController();
|
||||||
.get(`/api/import/sublines/${watchLine}`)
|
fetchSublinesCached(watchLine, controller.signal).then(setSublineOptions);
|
||||||
.then((res) => setSublineOptions(res.data))
|
return () => controller.abort();
|
||||||
.catch(() => setSublineOptions([]));
|
|
||||||
}, [watchLine]);
|
}, [watchLine]);
|
||||||
|
|
||||||
const computeImageChanges = useCallback((): ImageChanges | null => {
|
const computeImageChanges = useCallback((): ImageChanges | null => {
|
||||||
@@ -462,29 +496,6 @@ export function ProductEditForm({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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 items-start justify-between">
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1">
|
<div className="flex-1 min-w-0 flex items-center gap-1">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -505,18 +516,33 @@ export function ProductEditForm({
|
|||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors px-2"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-5 w-5" />
|
<ExternalLink className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">View in Backend</TooltipContent>
|
<TooltipContent side="bottom">View in Backend</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex flex-wrap gap-2 items-center justify-start">
|
<div className="flex flex-wrap gap-2 items-center justify-start">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -558,12 +584,16 @@ export function ProductEditForm({
|
|||||||
{/* Images + sidebar fields */}
|
{/* Images + sidebar fields */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
{layoutMode === "minimal" ? (
|
||||||
|
<MiniImagePreview images={productImages} isLoading={isLoadingImages} />
|
||||||
|
) : (
|
||||||
<ImageManager
|
<ImageManager
|
||||||
images={productImages}
|
images={productImages}
|
||||||
setImages={setProductImages}
|
setImages={setProductImages}
|
||||||
isLoading={isLoadingImages}
|
isLoading={isLoadingImages}
|
||||||
maxRows={MODE_LAYOUTS[layoutMode].imageRows}
|
maxRows={MODE_LAYOUTS[layoutMode].imageRows}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{MODE_LAYOUTS[layoutMode].sidebarGroups > 0 && (
|
{MODE_LAYOUTS[layoutMode].sidebarGroups > 0 && (
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export interface LineOption {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LandingExtra {
|
||||||
|
extra_id: number;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
extra_cat_id: number;
|
||||||
|
path: string;
|
||||||
|
top_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductImage {
|
export interface ProductImage {
|
||||||
iid: number | string; // number for existing, string like "new-0" for added
|
iid: number | string; // number for existing, string like "new-0" for added
|
||||||
order: number;
|
order: number;
|
||||||
|
|||||||
@@ -1,15 +1,76 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2 } from "lucide-react";
|
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 { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||||
import { ProductEditForm } from "@/components/product-editor/ProductEditForm";
|
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
||||||
import type { SearchProduct, FieldOptions } from "@/components/product-editor/types";
|
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() {
|
export default function ProductEditor() {
|
||||||
const [selectedProduct, setSelectedProduct] = useState<SearchProduct | null>(null);
|
const [allProducts, setAllProducts] = useState<SearchProduct[]>([]);
|
||||||
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(null);
|
||||||
const [isLoadingOptions, setIsLoadingOptions] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
axios
|
axios
|
||||||
@@ -20,8 +81,253 @@ export default function ProductEditor() {
|
|||||||
toast.error("Failed to load field options");
|
toast.error("Failed to load field options");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoadingOptions(false));
|
.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) {
|
if (isLoadingOptions) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -32,24 +338,146 @@ export default function ProductEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-6 max-w-4xl space-y-6">
|
<div className="container mx-auto py-6 max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Product Editor</h1>
|
<h1 className="text-2xl font-bold">Product Editor</h1>
|
||||||
<p className="text-muted-foreground">
|
</div>
|
||||||
Search for a product and edit its fields. Only changed fields will be
|
<div className="flex items-center gap-3">
|
||||||
submitted.
|
<span className="text-sm text-muted-foreground">Layout: </span>
|
||||||
</p>
|
<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>
|
</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 && (
|
<TabsContent value="search" className="mt-4">
|
||||||
<ProductEditForm
|
<ProductSearch onSelect={handleSearchSelect} />
|
||||||
key={selectedProduct.pid}
|
</TabsContent>
|
||||||
product={selectedProduct}
|
|
||||||
fieldOptions={fieldOptions}
|
<TabsContent value="new" className="mt-4">
|
||||||
onClose={() => setSelectedProduct(null)}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user