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:
377
api/server.js
377
api/server.js
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user