Refactor to multi-category catalog with polished light mode

- Restructure from single filament table to multi-category product catalog
  (filamenti, stampaci, ploce, mlaznice, delovi, oprema)
- Add shared layout components (SiteHeader, SiteFooter, CategoryNav, Breadcrumb)
- Add reusable UI primitives (Badge, Button, Card, Modal, PriceDisplay, EmptyState)
- Add catalog components (CatalogPage, ProductTable, ProductGrid, FilamentCard, ProductCard)
- Add admin dashboard with sidebar navigation and category management
- Add product API endpoints and database migrations
- Add SEO pages (politika-privatnosti, uslovi-koriscenja, robots.txt, sitemap.xml)
- Fix light mode: gradient text contrast, category nav accessibility,
  surface tokens, card shadows, CTA section theming
This commit is contained in:
DaX
2026-02-21 21:56:17 +01:00
parent a854fd5524
commit 1d3d11afec
62 changed files with 8618 additions and 358 deletions

View File

@@ -385,6 +385,383 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
}
});
// Slug generation helper
const generateSlug = (name) => {
const base = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const suffix = Math.random().toString(36).substring(2, 8);
return `${base}-${suffix}`;
};
// Products endpoints
// List/filter products (PUBLIC)
app.get('/api/products', async (req, res) => {
try {
const { category, condition, printer_model, in_stock, search } = req.query;
const conditions = [];
const params = [];
let paramIndex = 1;
if (category) {
conditions.push(`p.category = $${paramIndex++}`);
params.push(category);
}
if (condition) {
conditions.push(`p.condition = $${paramIndex++}`);
params.push(condition);
}
if (printer_model) {
conditions.push(`EXISTS (
SELECT 1 FROM product_printer_compatibility ppc
JOIN printer_models pm ON pm.id = ppc.printer_model_id
WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++}
)`);
params.push(printer_model);
}
if (in_stock === 'true') {
conditions.push(`p.quantity > 0`);
} else if (in_stock === 'false') {
conditions.push(`p.quantity = 0`);
}
if (search) {
conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT p.*,
COALESCE(
json_agg(
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
) FILTER (WHERE pm.id IS NOT NULL),
'[]'
) AS compatible_printers
FROM products p
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
${whereClause}
GROUP BY p.id
ORDER BY p.created_at DESC`,
params
);
res.json(result.rows);
} catch (error) {
console.error('Error fetching products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Get single product (PUBLIC)
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`SELECT p.*,
COALESCE(
json_agg(
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
) FILTER (WHERE pm.id IS NOT NULL),
'[]'
) AS compatible_printers
FROM products p
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
WHERE p.id = $1
GROUP BY p.id`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching product:', error);
res.status(500).json({ error: 'Failed to fetch product' });
}
});
// Create product (auth required)
app.post('/api/products', authenticateToken, async (req, res) => {
const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body;
try {
const slug = generateSlug(name);
const result = await pool.query(
`INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url]
);
const product = result.rows[0];
// Insert printer compatibility entries if provided
if (printer_model_ids && printer_model_ids.length > 0) {
const compatValues = printer_model_ids
.map((_, i) => `($1, $${i + 2})`)
.join(', ');
await pool.query(
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
[product.id, ...printer_model_ids]
);
}
res.json(product);
} catch (error) {
console.error('Error creating product:', error);
res.status(500).json({ error: 'Failed to create product' });
}
});
// Update product (auth required)
app.put('/api/products/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body;
try {
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
let result;
if (hasSaleFields) {
result = await pool.query(
`UPDATE products
SET name = $1, description = $2, category = $3, condition = $4,
price = $5, quantity = $6, image_url = $7,
sale_percentage = $8, sale_active = $9,
sale_start_date = $10, sale_end_date = $11,
updated_at = CURRENT_TIMESTAMP
WHERE id = $12 RETURNING *`,
[name, description, category, condition, price, parseInt(quantity) || 0, image_url,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
} else {
result = await pool.query(
`UPDATE products
SET name = $1, description = $2, category = $3, condition = $4,
price = $5, quantity = $6, image_url = $7,
updated_at = CURRENT_TIMESTAMP
WHERE id = $8 RETURNING *`,
[name, description, category, condition, price, parseInt(quantity) || 0, image_url, id]
);
}
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
// Update printer compatibility if provided
if (printer_model_ids !== undefined) {
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
if (printer_model_ids && printer_model_ids.length > 0) {
const compatValues = printer_model_ids
.map((_, i) => `($1, $${i + 2})`)
.join(', ');
await pool.query(
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
[id, ...printer_model_ids]
);
}
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating product:', error);
res.status(500).json({ error: 'Failed to update product' });
}
});
// Delete product (auth required)
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ success: true });
} catch (error) {
console.error('Error deleting product:', error);
res.status(500).json({ error: 'Failed to delete product' });
}
});
// Bulk sale update for products (auth required)
app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => {
const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
try {
let query;
let params;
if (productIds && productIds.length > 0) {
query = `
UPDATE products
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY($5)
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds];
} else {
query = `
UPDATE products
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
}
const result = await pool.query(query, params);
res.json({
success: true,
updatedCount: result.rowCount,
updatedProducts: result.rows
});
} catch (error) {
console.error('Error updating product sale:', error);
res.status(500).json({ error: 'Failed to update product sale' });
}
});
// Printer models endpoints
// List all printer models (PUBLIC)
app.get('/api/printer-models', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
res.json(result.rows);
} catch (error) {
console.error('Error fetching printer models:', error);
res.status(500).json({ error: 'Failed to fetch printer models' });
}
});
// Analytics endpoints
// Aggregated inventory stats (auth required)
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
try {
const filamentStats = await pool.query(`
SELECT
COUNT(*) AS total_skus,
COALESCE(SUM(kolicina), 0) AS total_units,
COALESCE(SUM(refill), 0) AS total_refills,
COALESCE(SUM(spulna), 0) AS total_spools,
COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus,
COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value
FROM filaments
`);
const productStats = await pool.query(`
SELECT
COUNT(*) AS total_skus,
COALESCE(SUM(quantity), 0) AS total_units,
COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus,
COALESCE(SUM(quantity * price), 0) AS total_inventory_value,
COUNT(*) FILTER (WHERE category = 'printer') AS printers,
COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates,
COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles,
COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts,
COUNT(*) FILTER (WHERE category = 'accessory') AS accessories
FROM products
`);
const filament = filamentStats.rows[0];
const product = productStats.rows[0];
res.json({
filaments: {
total_skus: parseInt(filament.total_skus),
total_units: parseInt(filament.total_units),
total_refills: parseInt(filament.total_refills),
total_spools: parseInt(filament.total_spools),
out_of_stock_skus: parseInt(filament.out_of_stock_skus),
total_inventory_value: parseInt(filament.total_inventory_value)
},
products: {
total_skus: parseInt(product.total_skus),
total_units: parseInt(product.total_units),
out_of_stock_skus: parseInt(product.out_of_stock_skus),
total_inventory_value: parseInt(product.total_inventory_value),
by_category: {
printers: parseInt(product.printers),
build_plates: parseInt(product.build_plates),
nozzles: parseInt(product.nozzles),
spare_parts: parseInt(product.spare_parts),
accessories: parseInt(product.accessories)
}
},
combined: {
total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus),
total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value),
out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus)
}
});
} catch (error) {
console.error('Error fetching inventory analytics:', error);
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
}
});
// Active sales overview (auth required)
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
try {
const filamentSales = await pool.query(`
SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date,
ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price
FROM filaments
WHERE sale_active = true
ORDER BY sale_percentage DESC
`);
const productSales = await pool.query(`
SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date,
ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price
FROM products
WHERE sale_active = true
ORDER BY sale_percentage DESC
`);
res.json({
filaments: {
count: filamentSales.rowCount,
items: filamentSales.rows
},
products: {
count: productSales.rowCount,
items: productSales.rows
},
total_active_sales: filamentSales.rowCount + productSales.rowCount
});
} catch (error) {
console.error('Error fetching sales analytics:', error);
res.status(500).json({ error: 'Failed to fetch sales analytics' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});