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}`);
});

18
app/delovi/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Rezervni Delovi (Spare Parts)',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
openGraph: {
title: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
url: 'https://filamenteka.rs/delovi',
},
alternates: {
canonical: 'https://filamenteka.rs/delovi',
},
};
export default function DeloviLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/delovi/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function DeloviPage() {
const category = getCategoryBySlug('delovi')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="delovi" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Rezervni Delovi' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Rezervni Delovi
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalni Bambu Lab rezervni delovi i spare parts. AMS moduli, extruder delovi, kablovi, senzori i drugi komponenti za odrzavanje i popravku vaseg 3D stampaca. Kompatibilni sa svim Bambu Lab serijama - A1, P1 i X1.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

18
app/filamenti/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Filamenti | PLA, PETG, ABS',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
openGraph: {
title: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
url: 'https://filamenteka.rs/filamenti',
},
alternates: {
canonical: 'https://filamenteka.rs/filamenti',
},
};
export default function FilamentiLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/filamenti/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function FilamentiPage() {
const category = getCategoryBySlug('filamenti')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="filamenti" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Filamenti' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Filamenti
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalni Bambu Lab filamenti za 3D stampac. U ponudi imamo PLA, PETG, ABS, TPU i mnoge druge materijale u razlicitim finishima - Basic, Matte, Silk, Sparkle, Translucent, Metal i drugi. Svi filamenti su originalni Bambu Lab proizvodi, neotvoreni i u fabrickom pakovanju. Dostupni kao refill pakovanje ili na spulni.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -4,8 +4,27 @@ import { BackToTop } from '../src/components/BackToTop'
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
export const metadata: Metadata = {
title: 'Filamenteka',
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
metadataBase: new URL('https://filamenteka.rs'),
title: {
default: 'Bambu Lab Oprema Srbija | Filamenteka',
template: '%s | Filamenteka',
},
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, 3D stampaci, build plate podloge, mlaznice, rezervni delovi i oprema.',
openGraph: {
type: 'website',
locale: 'sr_RS',
siteName: 'Filamenteka',
title: 'Bambu Lab Oprema Srbija | Filamenteka',
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji.',
url: 'https://filamenteka.rs',
},
robots: {
index: true,
follow: true,
},
alternates: {
canonical: 'https://filamenteka.rs',
},
}
export default function RootLayout({
@@ -13,27 +32,40 @@ export default function RootLayout({
}: {
children: React.ReactNode
}) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Filamenteka',
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji',
url: 'https://filamenteka.rs',
telephone: '+381631031048',
address: {
'@type': 'PostalAddress',
addressCountry: 'RS',
},
priceRange: 'RSD',
}
return (
<html lang="sr" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
document.documentElement.classList.add('no-transitions');
// Apply dark mode immediately for admin pages
if (window.location.pathname.startsWith('/upadaj')) {
if (window.location.pathname.startsWith('/upadaj') || window.location.pathname.startsWith('/dashboard')) {
document.documentElement.classList.add('dark');
} else {
// For non-admin pages, check localStorage
try {
const darkMode = localStorage.getItem('darkMode');
var darkMode = localStorage.getItem('darkMode');
if (darkMode === 'true') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
}
// Remove no-transitions class after a short delay
window.addEventListener('load', function() {
setTimeout(function() {
document.documentElement.classList.remove('no-transitions');
@@ -43,6 +75,10 @@ export default function RootLayout({
`,
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body suppressHydrationWarning>
{children}

18
app/mlaznice/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne)',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
openGraph: {
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
url: 'https://filamenteka.rs/mlaznice',
},
alternates: {
canonical: 'https://filamenteka.rs/mlaznice',
},
};
export default function MlaznicaLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/mlaznice/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function MlaznicePage() {
const category = getCategoryBySlug('mlaznice')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="mlaznice" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Mlaznice i Hotend' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Mlaznice i Hotend
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab nozzle mlaznice i hotend moduli. Hardened steel, stainless steel i obicne mlaznice u razlicitim precnicima. Complete hotend zamene za A1, P1 i X1 serije. Originalni Bambu Lab delovi u privatnoj prodaji.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

18
app/oprema/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Oprema i Dodaci (Accessories)',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
openGraph: {
title: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
url: 'https://filamenteka.rs/oprema',
},
alternates: {
canonical: 'https://filamenteka.rs/oprema',
},
};
export default function OpremaLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/oprema/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function OpremaPage() {
const category = getCategoryBySlug('oprema')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="oprema" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Oprema i Dodaci' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Oprema i Dodaci
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab oprema i dodaci za 3D stampace. AMS (Automatic Material System), spool holderi, alati za odrzavanje, filament drajeri i druga dodatna oprema. Sve sto vam treba za optimalan rad vaseg Bambu Lab 3D stampaca.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,273 +1,380 @@
'use client'
import { useState, useEffect } from 'react';
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
import { SaleCountdown } from '../src/components/SaleCountdown';
import ColorRequestModal from '../src/components/ColorRequestModal';
import { Filament } from '../src/types/filament';
import { filamentService } from '../src/services/api';
import { trackEvent } from '../src/components/MatomoAnalytics';
import Link from 'next/link';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { SaleCountdown } from '@/src/components/SaleCountdown';
import ColorRequestModal from '@/src/components/ColorRequestModal';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
import { getEnabledCategories } from '@/src/config/categories';
import { filamentService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
export default function Home() {
const [filaments, setFilaments] = useState<Filament[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
// Removed V1/V2 toggle - now only using V2
const [mounted, setMounted] = useState(false);
const categories = getEnabledCategories();
useEffect(() => { setMounted(true); }, []);
// Initialize dark mode from localStorage after mounting
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved) {
setDarkMode(JSON.parse(saved));
}
}, []);
// Update dark mode
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
const fetchFilaments = async () => {
try {
setLoading(true);
setError(null);
const filamentsData = await filamentService.getAll();
setFilaments(filamentsData);
} catch (err: any) {
console.error('API Error:', err);
// More descriptive error messages
if (err.code === 'ERR_NETWORK') {
setError('Network Error - Unable to connect to API');
} else if (err.response) {
setError(`Server error: ${err.response.status} - ${err.response.statusText}`);
} else if (err.request) {
setError('No response from server - check if API is running');
} else {
setError(err.message || 'Greška pri učitavanju filamenata');
}
} finally {
setLoading(false);
const data = await filamentService.getAll();
setFilaments(data);
} catch (err) {
console.error('Failed to fetch filaments for sale check:', err);
}
};
useEffect(() => {
fetchFilaments();
// Auto-refresh data every 30 seconds to stay in sync
const interval = setInterval(() => {
fetchFilaments();
}, 30000);
// Also refresh when window regains focus
const handleFocus = () => {
fetchFilaments();
};
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
};
}, []);
const handleLogoClick = () => {
setResetKey(prev => prev + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
<div className="flex-1 flex flex-col sm:flex-row justify-center items-center gap-1 sm:gap-3 text-sm sm:text-lg">
<span className="text-blue-700 dark:text-blue-300 font-medium animate-pulse text-center">
Kupovina po gramu dostupna
</span>
<span className="hidden sm:inline text-gray-400 dark:text-gray-600"></span>
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse text-center">
Popust za 5+ komada
</span>
</div>
<div className="flex-shrink-0">
{mounted ? (
<button
onClick={() => {
setDarkMode(!darkMode);
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
}}
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
>
{darkMode ? '☀️' : '🌙'}
</button>
) : (
<div className="w-10 h-10" />
)}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Logo centered above content */}
<div className="flex justify-center mb-8">
<button
onClick={handleLogoClick}
className="group transition-transform duration-200"
title="Klikni za reset"
>
{/* Using next/image would cause preload, so we use regular img with loading="lazy" */}
<img
src="/logo.png"
alt="Filamenteka"
loading="lazy"
decoding="async"
className="h-36 sm:h-44 md:h-52 w-auto drop-shadow-lg group-hover:drop-shadow-2xl transition-all duration-200"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
</button>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-8">
<a
href="https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Homepage')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Kupi na Kupujem Prodajem
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Pozovi +381 63 103 1048
</a>
<button
onClick={() => {
setShowColorRequestModal(true);
trackEvent('Navigation', 'Request Color', 'Homepage');
}}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Zatraži Novu Boju
</button>
</div>
<SaleCountdown
hasActiveSale={filaments.some(f => f.sale_active === true)}
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
saleEndDate={(() => {
const activeSales = filaments.filter(f => f.sale_active === true && f.sale_end_date);
if (activeSales.length === 0) return null;
const latestSale = activeSales.reduce((latest, current) => {
const activeSaleFilaments = filaments.filter(f => f.sale_active === true);
const hasActiveSale = activeSaleFilaments.length > 0;
const maxSalePercentage = hasActiveSale
? Math.max(...activeSaleFilaments.map(f => f.sale_percentage || 0), 0)
: 0;
const saleEndDate = (() => {
const withEndDate = activeSaleFilaments.filter(f => f.sale_end_date);
if (withEndDate.length === 0) return null;
return withEndDate.reduce((latest, current) => {
if (!latest.sale_end_date) return current;
if (!current.sale_end_date) return latest;
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
}).sale_end_date;
return latestSale;
})()}
}).sale_end_date ?? null;
})();
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
{/* ═══ HERO ═══ */}
<section className="relative overflow-hidden noise-overlay">
{/* Rich multi-layer gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-950 via-purple-950 to-indigo-950" />
{/* Floating color orbs — big, bright, energetic */}
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute w-[600px] h-[600px] -top-48 -left-48 rounded-full opacity-40 blur-[100px] animate-float"
style={{ background: 'radial-gradient(circle, #3b82f6, transparent)' }} />
<div className="absolute w-[500px] h-[500px] top-10 right-0 rounded-full opacity-40 blur-[80px] animate-float-slow"
style={{ background: 'radial-gradient(circle, #a855f7, transparent)' }} />
<div className="absolute w-[450px] h-[450px] -bottom-24 left-1/3 rounded-full opacity-40 blur-[90px] animate-float-delayed"
style={{ background: 'radial-gradient(circle, #f59e0b, transparent)' }} />
<div className="absolute w-[400px] h-[400px] bottom-10 right-1/4 rounded-full opacity-40 blur-[80px] animate-float"
style={{ background: 'radial-gradient(circle, #22c55e, transparent)' }} />
<div className="absolute w-[350px] h-[350px] top-1/2 left-10 rounded-full opacity-35 blur-[70px] animate-float-slow"
style={{ background: 'radial-gradient(circle, #ef4444, transparent)' }} />
<div className="absolute w-[300px] h-[300px] top-1/4 right-1/3 rounded-full opacity-35 blur-[75px] animate-float-delayed"
style={{ background: 'radial-gradient(circle, #06b6d4, transparent)' }} />
</div>
{/* Grid pattern overlay */}
<div className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
backgroundSize: '60px 60px'
}} />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-28 lg:py-32">
<div className="flex flex-col items-center text-center reveal-up">
{/* Logo */}
<img
src="/logo.png"
alt="Filamenteka"
loading="eager"
decoding="async"
className="h-24 sm:h-32 md:h-40 w-auto drop-shadow-2xl mb-8"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
{/* Pricing Information */}
<div className="mb-6 space-y-4">
{/* Reusable Spool Price Notice */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-blue-800 dark:text-blue-200 font-medium">
Cena višekratne špulne: 499 RSD
{/* Tagline */}
<h1
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-white mb-5 tracking-tight leading-[1.1]"
style={{ fontFamily: 'var(--font-display)' }}
>
Bambu Lab oprema
<br />
<span className="hero-gradient-text">
u Srbiji
</span>
</div>
</div>
</h1>
<p className="text-lg sm:text-xl text-gray-300 max-w-xl mb-10 leading-relaxed reveal-up reveal-delay-1">
Filamenti, stampaci, podloge, mlaznice, delovi i oprema.
<br className="hidden sm:block" />
Originalni proizvodi, privatna prodaja.
</p>
{/* Selling by Grams Notice */}
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
{/* CTA */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 reveal-up reveal-delay-2">
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Hero CTA')}
className="group inline-flex items-center justify-center gap-2.5 px-8 py-4
bg-blue-500 hover:bg-blue-600 text-white font-bold text-base rounded-2xl
shadow-lg shadow-blue-500/25
hover:shadow-2xl hover:shadow-blue-500/35
transition-all duration-300 active:scale-[0.97]"
style={{ fontFamily: 'var(--font-display)' }}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
<span className="text-green-800 dark:text-green-200 font-semibold">
Prodaja filamenta na grame - idealno za testiranje materijala ili manje projekte
</span>
</div>
<div className="flex flex-wrap justify-center gap-4 text-sm">
<span className="text-green-700 dark:text-green-300 font-medium">50gr - 299 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">100gr - 499 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">200gr - 949 RSD</span>
</div>
</div>
</div>
</div>
Kupi na KupujemProdajem
<svg className="w-4 h-4 opacity-60 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</a>
<FilamentTableV2
key={resetKey}
filaments={filaments}
/>
</main>
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col justify-center items-center gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
<a
href="tel:+381631031048"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
onClick={() => trackEvent('Contact', 'Phone Call', 'Hero CTA')}
className="inline-flex items-center justify-center gap-2.5 px-8 py-4
text-white/90 hover:text-white font-semibold text-base rounded-2xl
border border-white/20 hover:border-white/40
hover:bg-white/[0.08]
transition-all duration-300 active:scale-[0.97]"
style={{ fontFamily: 'var(--font-display)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
+381 63 103 1048
</a>
</div>
</div>
</div>
</footer>
{/* Color Request Modal */}
{/* Bottom fade */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[var(--surface-primary)] to-transparent z-10" />
</section>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8 relative z-20">
{/* Sale Banner */}
<SaleCountdown
hasActiveSale={hasActiveSale}
maxSalePercentage={maxSalePercentage}
saleEndDate={saleEndDate}
/>
{/* ═══ CATEGORIES ═══ */}
<section className="py-12 sm:py-16">
<div className="text-center mb-10 sm:mb-14">
<h2
className="text-4xl sm:text-5xl font-extrabold tracking-tight mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Sta nudimo
</h2>
<p style={{ color: 'var(--text-secondary)' }} className="text-base sm:text-lg max-w-md mx-auto">
Kompletna ponuda originalne Bambu Lab opreme
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 auto-rows-auto">
{categories.map((cat, i) => {
// First card (filamenti) is featured — spans 2 cols on lg
const isFeatured = i === 0;
// Last card spans 2 cols on sm/lg for variety
const isWide = i === categories.length - 1;
return (
<Link
key={cat.slug}
href={`/${cat.slug}`}
onClick={() => trackEvent('Navigation', 'Category Click', cat.label)}
className={`category-card group block rounded-2xl reveal-up reveal-delay-${i + 1} ${
isFeatured ? 'lg:col-span-2 lg:row-span-2' : ''
} ${isWide ? 'sm:col-span-2 lg:col-span-1' : ''}`}
style={{
background: `linear-gradient(135deg, ${cat.colorHex}, ${cat.colorHex}cc)`,
boxShadow: `0 8px 32px -8px ${cat.colorHex}35`,
}}
>
<div className={`relative z-10 ${isFeatured ? 'p-8 sm:p-10' : 'p-6 sm:p-7'}`}>
{/* SVG Icon */}
<div className={`${isFeatured ? 'w-14 h-14' : 'w-11 h-11'} bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center mb-4`}>
<CategoryIcon slug={cat.slug} className={isFeatured ? 'w-7 h-7 text-white' : 'w-5 h-5 text-white'} />
</div>
<h3 className={`${isFeatured ? 'text-2xl lg:text-3xl' : 'text-lg'} font-bold text-white tracking-tight mb-2`}
style={{ fontFamily: 'var(--font-display)' }}>
{cat.label}
</h3>
<p className={`text-white/75 ${isFeatured ? 'text-base' : 'text-sm'} leading-relaxed mb-5`}>
{cat.description}
</p>
<div className="flex items-center text-sm font-bold text-white/90 group-hover:text-white transition-colors">
Pogledaj ponudu
<svg className="w-4 h-4 ml-1.5 group-hover:translate-x-1.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</div>
</div>
</Link>
);
})}
</div>
</section>
{/* ═══ INFO CARDS ═══ */}
<section className="pb-12 sm:pb-16">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-5">
{/* Reusable Spool Price */}
<div
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#3b82f6', background: 'var(--surface-elevated)' }}
>
<div className="flex-shrink-0 w-11 h-11 bg-blue-500/10 dark:bg-blue-500/15 rounded-xl flex items-center justify-center">
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
<div>
<h3 className="font-extrabold text-base mb-1" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
Visekratna spulna
</h3>
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Cena visekratne spulne: <span className="font-extrabold text-blue-500">499 RSD</span>
</p>
</div>
</div>
{/* Selling by Grams */}
<div
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#10b981', background: 'var(--surface-elevated)' }}
>
<div className="flex-shrink-0 w-11 h-11 bg-emerald-500/10 dark:bg-emerald-500/15 rounded-xl flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z" />
</svg>
</div>
<div>
<h3 className="font-extrabold text-base mb-1.5" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
Prodaja na grame
</h3>
<p className="text-sm font-medium mb-2.5" style={{ color: 'var(--text-secondary)' }}>
Idealno za testiranje materijala ili manje projekte.
</p>
<div className="flex flex-wrap gap-2">
{[
{ g: '50g', price: '299' },
{ g: '100g', price: '499' },
{ g: '200g', price: '949' },
].map(({ g, price }) => (
<span key={g} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold bg-emerald-500/10 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
{g} {price} RSD
</span>
))}
</div>
</div>
</div>
</div>
</section>
{/* ═══ DISCLAIMER ═══ */}
<section className="pb-12 sm:pb-16">
<div className="rounded-2xl border border-amber-200/60 dark:border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/[0.06] p-5 sm:p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 bg-amber-400/20 dark:bg-amber-500/15 rounded-lg flex items-center justify-center mt-0.5">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="text-sm leading-relaxed text-amber-900 dark:text-amber-200/80">
Ovo je privatna prodaja fizickog lica. Filamenteka nije registrovana prodavnica.
Sva kupovina se odvija preko KupujemProdajem platforme.
</p>
</div>
</div>
</section>
{/* ═══ CONTACT CTA ═══ */}
<section className="pb-16 sm:pb-20">
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-slate-100 via-slate-50 to-blue-50 dark:bg-none dark:from-transparent dark:via-transparent dark:to-transparent dark:bg-[#0f172a] dark:noise-overlay border border-slate-200/80 dark:border-transparent">
<div className="relative z-10 p-8 sm:p-12 lg:p-14 text-center">
<h2
className="text-2xl sm:text-3xl lg:text-4xl font-extrabold mb-3 tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Zainteresovani?
</h2>
<p className="mb-8 max-w-md mx-auto text-base" style={{ color: 'var(--text-muted)' }}>
Pogledajte ponudu na KupujemProdajem ili nas kontaktirajte direktno.
</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 sm:gap-4">
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Contact CTA')}
className="group inline-flex items-center gap-2.5 px-7 py-3.5
bg-blue-500 hover:bg-blue-600 text-white
font-bold rounded-xl
shadow-lg shadow-blue-500/25
hover:shadow-xl hover:shadow-blue-500/35
transition-all duration-300 active:scale-[0.97]"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
Kupi na KupujemProdajem
<svg className="w-4 h-4 opacity-50 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</a>
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Contact CTA')}
className="inline-flex items-center gap-2.5 px-7 py-3.5
font-semibold rounded-xl
text-slate-700 dark:text-white/90 hover:text-slate-900 dark:hover:text-white
border border-slate-300 dark:border-white/25 hover:border-slate-400 dark:hover:border-white/50
hover:bg-slate-50 dark:hover:bg-white/[0.1]
transition-all duration-300 active:scale-[0.97]"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
+381 63 103 1048
</a>
</div>
{/* Color Request */}
<div className="mt-6">
<button
onClick={() => {
setShowColorRequestModal(true);
trackEvent('Navigation', 'Request Color', 'Contact CTA');
}}
className="inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 text-slate-500 hover:text-slate-800 dark:text-white/70 dark:hover:text-white"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
</svg>
Ne nalazite boju? Zatrazite novu
</button>
</div>
</div>
</div>
</section>
</main>
<SiteFooter />
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}

18
app/ploce/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Build Plate (Podloga)',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
openGraph: {
title: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
url: 'https://filamenteka.rs/ploce',
},
alternates: {
canonical: 'https://filamenteka.rs/ploce',
},
};
export default function PloceLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/ploce/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function PlocePage() {
const category = getCategoryBySlug('ploce')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="ploce" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Build Plate (Podloge)' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Build Plate Podloge
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalne Bambu Lab build plate podloge za 3D stampanje. Cool Plate, Engineering Plate, High Temp Plate i Textured PEI Plate. Kompatibilne sa A1 Mini, A1, P1S, X1C i drugim modelima. Nove i polovne podloge u privatnoj prodaji.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -0,0 +1,205 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
export const metadata: Metadata = {
title: 'Politika Privatnosti',
description: 'Politika privatnosti sajta Filamenteka — informacije o prikupljanju i obradi podataka, kolacicima, analitici i pravima korisnika.',
alternates: {
canonical: 'https://filamenteka.rs/politika-privatnosti',
},
};
export default function PolitikaPrivatnostiPage() {
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Politika Privatnosti' },
]} />
<article className="mt-6">
<h1
className="text-3xl sm:text-4xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Politika Privatnosti
</h1>
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
Poslednje azuriranje: Februar 2026
</p>
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
1. Uvod
</h2>
<p>
Filamenteka (filamenteka.rs) postuje vasu privatnost. Ova politika privatnosti
objasnjava koje podatke prikupljamo, kako ih koristimo i koja prava imate u vezi
sa vasim podacima.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
2. Podaci koje prikupljamo
</h2>
<h3
className="text-lg font-semibold mt-4 mb-2"
style={{ color: 'var(--text-primary)' }}
>
2.1 Analitika (Matomo)
</h3>
<p className="mb-3">
Koristimo Matomo, platformu za web analitiku otvorenog koda, koja je hostovana na
nasem sopstvenom serveru. Matomo prikuplja sledece anonimizovane podatke:
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Anonimizovana IP adresa (poslednja dva okteta su maskirana)</li>
<li>Tip uredjaja i operativni sistem</li>
<li>Pregledac i rezolucija ekrana</li>
<li>Posecene stranice i vreme posete</li>
<li>Referalna stranica (odakle ste dosli)</li>
</ul>
<p className="mt-3">
Ovi podaci se koriste iskljucivo za razumevanje kako posetioci koriste sajt i za
poboljsanje korisnickog iskustva. Podaci se ne dele sa trecim stranama.
</p>
<h3
className="text-lg font-semibold mt-6 mb-2"
style={{ color: 'var(--text-primary)' }}
>
2.2 Podaci iz kontakt forme (Zahtev za boju)
</h3>
<p>
Kada podnesete zahtev za novu boju filamenta, prikupljamo sledece podatke koje
dobrovoljno unosite:
</p>
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
<li>Vase ime</li>
<li>Broj telefona</li>
<li>Zeljena boja filamenta i poruka</li>
</ul>
<p className="mt-3">
Ovi podaci se koriste iskljucivo za obradu vaseg zahteva i kontaktiranje u vezi
sa dostupnoscu trazene boje. Ne koristimo ih u marketinske svrhe niti ih delimo
sa trecim stranama.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
3. Kolacici
</h2>
<p>
Filamenteka ne koristi kolacice za pracenje korisnika niti za marketinske svrhe.
Matomo analitika je konfigurisana tako da radi bez kolacica za pracenje. Jedini
lokalni podaci koji se cuvaju u vasem pregledacu su podesavanja interfejsa (npr.
tamni rezim), koja se cuvaju u localStorage i nikada se ne salju na server.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
4. Korisnicki nalozi
</h2>
<p>
Sajt ne nudi mogucnost registracije niti kreiranja korisnickih naloga za
posetioce. Ne prikupljamo niti cuvamo lozinke, email adrese ili druge podatke
za autentifikaciju posetilaca.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
5. Odjava od analitike (Matomo Opt-Out)
</h2>
<p>
Ako zelite da budete iskljuceni iz Matomo analitike, mozete aktivirati &quot;Do Not
Track&quot; opciju u vasem pregledacu. Matomo je konfigurisan da postuje ovo
podesavanje i nece prikupljati podatke o vasim posetama.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
6. GDPR uskladjenost
</h2>
<p>
U skladu sa Opstom uredbom o zastiti podataka (GDPR) i Zakonom o zastiti podataka
o licnosti Republike Srbije, imate sledeca prava:
</p>
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
<li>Pravo na pristup vasim podacima</li>
<li>Pravo na ispravku netacnih podataka</li>
<li>Pravo na brisanje podataka (&quot;pravo na zaborav&quot;)</li>
<li>Pravo na ogranicenje obrade</li>
<li>Pravo na prigovor na obradu podataka</li>
</ul>
<p className="mt-3">
Za ostvarivanje bilo kog od ovih prava, kontaktirajte nas putem telefona
navedenog na sajtu.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
7. Rukovalac podacima
</h2>
<p>
Rukovalac podacima je Filamenteka, privatna prodaja fizickog lica.
</p>
<ul className="list-none space-y-1 mt-2">
<li>Sajt: filamenteka.rs</li>
<li>Telefon: +381 63 103 1048</li>
</ul>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
8. Izmene politike privatnosti
</h2>
<p>
Zadrzavamo pravo da azuriramo ovu politiku privatnosti u bilo kom trenutku.
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
izmene.
</p>
</section>
</div>
</article>
</main>
<SiteFooter />
</div>
);
}

18
app/stampaci/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
openGraph: {
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
url: 'https://filamenteka.rs/stampaci',
},
alternates: {
canonical: 'https://filamenteka.rs/stampaci',
},
};
export default function StampaciLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

43
app/stampaci/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function StampaciPage() {
const category = getCategoryBySlug('stampaci')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="stampaci" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: '3D Stampaci' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab 3D Stampaci
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab 3D stampaci u privatnoj prodaji. U ponudi imamo A1 Mini, A1, P1S, X1C i druge modele. Novi i polovni stampaci u razlicitim stanjima. Svi stampaci su originalni Bambu Lab proizvodi sa svom originalnom opremom.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -180,22 +180,22 @@ export default function ColorsManagement() {
};
if (loading) {
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
return <div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
<div className="text-gray-600 dark:text-white/40">Učitavanje...</div>
</div>;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] transition-colors">
<div className="flex">
{/* Sidebar */}
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen">
<div className="w-64 bg-white dark:bg-white/[0.04] shadow-lg h-screen">
<div className="p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
<nav className="space-y-2">
<a
href="/dashboard"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
className="block px-4 py-2 text-gray-700 dark:text-white/60 hover:bg-gray-100 dark:hover:bg-white/[0.06] rounded"
>
Filamenti
</a>
@@ -211,7 +211,7 @@ export default function ColorsManagement() {
{/* Main Content */}
<div className="flex-1">
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
<header className="bg-white dark:bg-white/[0.04] shadow transition-colors">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
@@ -249,7 +249,7 @@ export default function ColorsManagement() {
{mounted && (
<button
onClick={() => setDarkMode(!darkMode)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
className="px-4 py-2 bg-gray-200 dark:bg-white/[0.06] text-gray-800 dark:text-white/70 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
>
{darkMode ? '☀️' : '🌙'}
@@ -292,7 +292,7 @@ export default function ColorsManagement() {
placeholder="Pretraži boje..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-white/60 bg-white dark:bg-white/[0.04] border border-gray-300 dark:border-white/[0.08] rounded-lg focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -301,9 +301,9 @@ export default function ColorsManagement() {
</div>
{/* Colors Table */}
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<div className="overflow-x-auto bg-white dark:bg-white/[0.04] rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.06]">
<thead className="bg-gray-50 dark:bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
@@ -316,36 +316,36 @@ export default function ColorsManagement() {
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
})()}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
{colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((color) => (
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedColors.has(color.id)}
onChange={() => toggleColorSelection(color.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-gray-600"
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-white/[0.08]"
style={{ backgroundColor: color.hex }}
/>
</td>
@@ -389,7 +389,7 @@ export default function ColorsManagement() {
{/* Edit Modal */}
{editingColor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
<ColorForm
@@ -454,7 +454,7 @@ function ColorForm({
};
return (
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors"}>
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-white/[0.04] rounded-lg shadow transition-colors"}>
{!isModal && (
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
@@ -462,7 +462,7 @@ function ColorForm({
)}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Naziv boje</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Naziv boje</label>
<input
type="text"
name="name"
@@ -470,12 +470,12 @@ function ColorForm({
onChange={handleChange}
required
placeholder="npr. Crvena"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Hex kod boje</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Hex kod boje</label>
<div className="flex gap-2 items-center">
<input
type="color"
@@ -483,7 +483,7 @@ function ColorForm({
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
disabled={isBambuLabColor}
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-gray-600 rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
/>
<input
@@ -495,18 +495,18 @@ function ColorForm({
readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000"
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : ''}`}
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-[#060a14] cursor-not-allowed' : ''}`}
/>
</div>
{isBambuLabColor && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className="text-xs text-gray-500 dark:text-white/40 mt-1">
Bambu Lab predefinisana boja - hex kod se ne može menjati
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Refil</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Refil</label>
<input
type="number"
name="cena_refill"
@@ -516,12 +516,12 @@ function ColorForm({
min="0"
step="1"
placeholder="3499"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Spulna</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Spulna</label>
<input
type="number"
name="cena_spulna"
@@ -531,7 +531,7 @@ function ColorForm({
min="0"
step="1"
placeholder="3999"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@@ -539,7 +539,7 @@ function ColorForm({
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white/70 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Otkaži
</button>

View File

@@ -0,0 +1,13 @@
export function generateStaticParams() {
return [
{ category: 'stampaci' },
{ category: 'ploce' },
{ category: 'mlaznice' },
{ category: 'delovi' },
{ category: 'oprema' },
];
}
export default function CategoryLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,577 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams, notFound } from 'next/navigation';
import { productService, printerModelService } from '@/src/services/api';
import { Product, ProductCategory, ProductCondition, PrinterModel } from '@/src/types/product';
import '@/src/styles/select.css';
const CATEGORY_MAP: Record<string, { category: ProductCategory; label: string; plural: string }> = {
stampaci: { category: 'printer', label: 'Stampac', plural: 'Stampaci' },
ploce: { category: 'build_plate', label: 'Ploca', plural: 'Ploce' },
mlaznice: { category: 'nozzle', label: 'Mlaznica', plural: 'Mlaznice' },
delovi: { category: 'spare_part', label: 'Deo', plural: 'Delovi' },
oprema: { category: 'accessory', label: 'Oprema', plural: 'Oprema' },
};
const CONDITION_LABELS: Record<string, string> = {
new: 'Novo',
used_like_new: 'Korisceno - kao novo',
used_good: 'Korisceno - dobro',
used_fair: 'Korisceno - pristojno',
};
// Categories that support printer compatibility
const PRINTER_COMPAT_CATEGORIES: ProductCategory[] = ['build_plate', 'nozzle', 'spare_part'];
export default function CategoryPage() {
const params = useParams();
const slug = params.category as string;
const categoryConfig = CATEGORY_MAP[slug];
// If slug is not recognized, show not found
if (!categoryConfig) {
notFound();
}
const { category, label, plural } = categoryConfig;
const [products, setProducts] = useState<Product[]>([]);
const [printerModels, setPrinterModels] = useState<PrinterModel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<string>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedProducts, setSelectedProducts] = useState<Set<string>>(new Set());
const fetchProducts = async () => {
try {
setLoading(true);
const [data, models] = await Promise.all([
productService.getAll({ category }),
PRINTER_COMPAT_CATEGORIES.includes(category)
? printerModelService.getAll().catch(() => [])
: Promise.resolve([]),
]);
setProducts(data);
setPrinterModels(models);
} catch (err) {
setError('Greska pri ucitavanju proizvoda');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, [category]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const filteredAndSorted = useMemo(() => {
let filtered = products;
if (searchTerm) {
const search = searchTerm.toLowerCase();
filtered = products.filter(p =>
p.name.toLowerCase().includes(search) ||
p.description?.toLowerCase().includes(search)
);
}
return [...filtered].sort((a, b) => {
let aVal: any = a[sortField as keyof Product] || '';
let bVal: any = b[sortField as keyof Product] || '';
if (sortField === 'price' || sortField === 'stock') {
aVal = Number(aVal) || 0;
bVal = Number(bVal) || 0;
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
}
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [products, searchTerm, sortField, sortOrder]);
const handleSave = async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
try {
const dataToSave = {
...product,
category,
};
if (product.id) {
await productService.update(product.id, dataToSave);
} else {
await productService.create(dataToSave);
}
setEditingProduct(null);
setShowAddForm(false);
fetchProducts();
} catch (err: any) {
const msg = err.response?.data?.error || err.message || 'Greska pri cuvanju proizvoda';
setError(msg);
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovaj proizvod?')) return;
try {
await productService.delete(id);
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedProducts.size === 0) return;
if (!confirm(`Obrisati ${selectedProducts.size} proizvoda?`)) return;
try {
await Promise.all(Array.from(selectedProducts).map(id => productService.delete(id)));
setSelectedProducts(new Set());
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Bulk delete error:', err);
}
};
const toggleSelection = (id: string) => {
const next = new Set(selectedProducts);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedProducts(next);
};
const toggleSelectAll = () => {
if (selectedProducts.size === filteredAndSorted.length) {
setSelectedProducts(new Set());
} else {
setSelectedProducts(new Set(filteredAndSorted.map(p => p.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">{plural}</h1>
<p className="text-white/40 mt-1">{products.length} proizvoda ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingProduct && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
>
Dodaj {label.toLowerCase()}
</button>
)}
{selectedProducts.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
>
Obrisi izabrane ({selectedProducts.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi proizvode..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Add/Edit Form */}
{(showAddForm || editingProduct) && (
<ProductForm
product={editingProduct || undefined}
printerModels={printerModels}
showPrinterCompat={PRINTER_COMPAT_CATEGORIES.includes(category)}
onSave={handleSave}
onCancel={() => {
setEditingProduct(null);
setShowAddForm(false);
}}
/>
)}
{/* Products Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSorted.length > 0 && selectedProducts.size === filteredAndSorted.length}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</th>
<th onClick={() => handleSort('name')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Naziv {sortField === 'name' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('condition')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Stanje {sortField === 'condition' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('price')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Cena {sortField === 'price' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('stock')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Kolicina {sortField === 'stock' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Popust</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Akcije</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{filteredAndSorted.map(product => (
<tr key={product.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedProducts.has(product.id)}
onChange={() => toggleSelection(product.id)}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{product.image_url && (
<img src={product.image_url} alt={product.name} className="w-10 h-10 rounded object-cover" />
)}
<div>
<div className="text-sm font-medium text-white/90">{product.name}</div>
{product.description && (
<div className="text-xs text-white/40 truncate max-w-xs">{product.description}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-white/60">
{CONDITION_LABELS[product.condition] || product.condition}
</td>
<td className="px-4 py-3 text-sm font-bold text-white/90">
{product.price.toLocaleString('sr-RS')} RSD
</td>
<td className="px-4 py-3 text-sm">
{product.stock > 2 ? (
<span className="text-green-400 font-bold">{product.stock}</span>
) : product.stock > 0 ? (
<span className="text-yellow-400 font-bold">{product.stock}</span>
) : (
<span className="text-red-400 font-bold">0</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{product.sale_active && product.sale_percentage ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{product.sale_percentage}%
</span>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => {
setEditingProduct(product);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(product.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
{filteredAndSorted.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-white/40">
Nema proizvoda u ovoj kategoriji
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// Product Form Component
function ProductForm({
product,
printerModels,
showPrinterCompat,
onSave,
onCancel,
}: {
product?: Product;
printerModels: PrinterModel[];
showPrinterCompat: boolean;
onSave: (data: Partial<Product> & { printer_model_ids?: string[] }) => void;
onCancel: () => void;
}) {
const [formData, setFormData] = useState({
name: product?.name || '',
description: product?.description || '',
price: product?.price || 0,
condition: (product?.condition || 'new') as ProductCondition,
stock: product?.stock || 0,
image_url: product?.image_url || '',
attributes: JSON.stringify(product?.attributes || {}, null, 2),
});
const [selectedPrinterIds, setSelectedPrinterIds] = useState<string[]>([]);
// Load compatible printers on mount
useEffect(() => {
if (product?.compatible_printers) {
// Map names to IDs
const ids = printerModels
.filter(m => product.compatible_printers?.includes(m.name))
.map(m => m.id);
setSelectedPrinterIds(ids);
}
}, [product, printerModels]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? (parseFloat(value) || 0) : value,
}));
};
const togglePrinter = (id: string) => {
setSelectedPrinterIds(prev =>
prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('Naziv je obavezan');
return;
}
let attrs = {};
try {
attrs = formData.attributes.trim() ? JSON.parse(formData.attributes) : {};
} catch {
alert('Atributi moraju biti validan JSON');
return;
}
onSave({
id: product?.id,
name: formData.name,
description: formData.description || undefined,
price: formData.price,
condition: formData.condition,
stock: formData.stock,
image_url: formData.image_url || undefined,
attributes: attrs,
printer_model_ids: showPrinterCompat ? selectedPrinterIds : undefined,
});
};
return (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{product ? 'Izmeni proizvod' : 'Dodaj proizvod'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Naziv</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Opis</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena (RSD)</label>
<input
type="number"
name="price"
value={formData.price}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Stanje</label>
<select
name="condition"
value={formData.condition}
onChange={handleChange}
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="new">Novo</option>
<option value="used_like_new">Korisceno - kao novo</option>
<option value="used_good">Korisceno - dobro</option>
<option value="used_fair">Korisceno - pristojno</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Kolicina</label>
<input
type="number"
name="stock"
value={formData.stock}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">URL slike</label>
<input
type="url"
name="image_url"
value={formData.image_url}
onChange={handleChange}
placeholder="https://..."
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Atributi (JSON)</label>
<textarea
name="attributes"
value={formData.attributes}
onChange={handleChange}
rows={3}
placeholder='{"key": "value"}'
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Printer Compatibility */}
{showPrinterCompat && printerModels.length > 0 && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2 text-white/60">Kompatibilni stampaci</label>
<div className="flex flex-wrap gap-2">
{printerModels.map(model => (
<button
key={model.id}
type="button"
onClick={() => togglePrinter(model.id)}
className={`px-3 py-1 rounded text-sm transition-colors ${
selectedPrinterIds.includes(model.id)
? 'bg-blue-600 text-white'
: 'bg-white/[0.06] text-white/60 hover:bg-white/[0.08]'
}`}
>
{model.name}
</button>
))}
</div>
</div>
)}
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,460 @@
'use client';
import { useState, useEffect } from 'react';
import { analyticsService, filamentService, productService } from '@/src/services/api';
import { InventoryStats, SalesStats, Product } from '@/src/types/product';
import { Filament } from '@/src/types/filament';
type Tab = 'inventar' | 'prodaja' | 'posetioci' | 'nabavka';
const CATEGORY_LABELS: Record<string, string> = {
printer: 'Stampaci',
build_plate: 'Ploce',
nozzle: 'Mlaznice',
spare_part: 'Delovi',
accessory: 'Oprema',
};
export default function AnalitikaPage() {
const [activeTab, setActiveTab] = useState<Tab>('inventar');
const [inventoryStats, setInventoryStats] = useState<InventoryStats | null>(null);
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [inv, sales, fils, prods] = await Promise.all([
analyticsService.getInventory().catch(() => null),
analyticsService.getSales().catch(() => null),
filamentService.getAll().catch(() => []),
productService.getAll().catch(() => []),
]);
setInventoryStats(inv);
setSalesStats(sales);
setFilaments(fils);
setProducts(prods);
} catch (error) {
console.error('Error loading analytics:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const tabs: { key: Tab; label: string }[] = [
{ key: 'inventar', label: 'Inventar' },
{ key: 'prodaja', label: 'Prodaja' },
{ key: 'posetioci', label: 'Posetioci' },
{ key: 'nabavka', label: 'Nabavka' },
];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje analitike...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Analitika</h1>
<p className="text-white/40 mt-1">Pregled stanja inventara i prodaje</p>
</div>
{/* Tabs */}
<div className="flex border-b border-white/[0.06]">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-white/40 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Inventar Tab */}
{activeTab === 'inventar' && (
<div className="space-y-6">
{inventoryStats ? (
<>
{/* Filament stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Refili</p>
<p className="text-2xl font-bold text-green-400">{inventoryStats.filaments.total_refills}</p>
</div>
<div>
<p className="text-sm text-white/40">Spulne</p>
<p className="text-2xl font-bold text-blue-400">{inventoryStats.filaments.total_spools}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.filaments.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.filaments.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
</div>
{/* Product stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.products.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.products.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
{/* Category breakdown */}
{inventoryStats.products.by_category && Object.keys(inventoryStats.products.by_category).length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-white/40 mb-2">Po kategoriji</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{Object.entries(inventoryStats.products.by_category).map(([cat, count]) => (
<div key={cat} className="bg-white/[0.06] rounded p-3">
<p className="text-xs text-white/40">{CATEGORY_LABELS[cat] || cat}</p>
<p className="text-lg font-bold text-white">{count}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Combined */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Ukupno</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-white/40">Ukupno SKU-ova</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.combined.out_of_stock}</p>
</div>
</div>
</div>
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o inventaru nisu dostupni. Proverite API konekciju.</p>
{/* Fallback from direct data */}
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">Filamenata</p>
<p className="text-2xl font-bold text-white">{filaments.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Proizvoda</p>
<p className="text-2xl font-bold text-white">{products.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Nisko stanje (fil.)</p>
<p className="text-2xl font-bold text-yellow-400">
{filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0).length}
</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju (fil.)</p>
<p className="text-2xl font-bold text-red-400">
{filaments.filter(f => f.kolicina === 0).length}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Prodaja Tab */}
{activeTab === 'prodaja' && (
<div className="space-y-6">
{salesStats ? (
<>
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-2">Aktivni popusti</h3>
<p className="text-3xl font-bold text-purple-400">{salesStats.total_active_sales}</p>
</div>
{salesStats.filament_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti na popustu ({salesStats.filament_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.filament_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-purple-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.product_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi na popustu ({salesStats.product_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Kategorija</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.product_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-white/40">{CATEGORY_LABELS[sale.category] || sale.category}</td>
<td className="px-3 py-2 text-orange-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.filament_sales.length === 0 && salesStats.product_sales.length === 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6 text-center">
<p className="text-white/40">Nema aktivnih popusta</p>
</div>
)}
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o prodaji nisu dostupni. Proverite API konekciju.</p>
<div className="mt-4">
<p className="text-sm text-white/30">
Filamenti sa popustom: {filaments.filter(f => f.sale_active).length}
</p>
<p className="text-sm text-white/30">
Proizvodi sa popustom: {products.filter(p => p.sale_active).length}
</p>
</div>
</div>
)}
</div>
)}
{/* Posetioci Tab */}
{activeTab === 'posetioci' && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Analitika posetilaca</h3>
<p className="text-white/40 mb-4">
Matomo analitika je dostupna na eksternom dashboardu.
</p>
<a
href="https://analytics.demirix.dev"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Otvori Matomo analitiku
</a>
<p className="text-xs text-white/30 mt-3">
analytics.demirix.dev - Site ID: 7
</p>
</div>
)}
{/* Nabavka Tab */}
{activeTab === 'nabavka' && (
<div className="space-y-6">
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Preporuke za nabavku</h3>
<p className="text-white/40 text-sm mb-4">Na osnovu trenutnog stanja inventara</p>
{/* Critical (out of stock) */}
{(() => {
const critical = filaments.filter(f => f.kolicina === 0);
return critical.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-red-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500 inline-block" />
Kriticno - nema na stanju ({critical.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{critical.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-red-400 font-bold">0</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* Warning (low stock) */}
{(() => {
const warning = filaments.filter(f => f.kolicina > 0 && f.kolicina <= 2);
return warning.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-yellow-500 inline-block" />
Upozorenje - nisko stanje ({warning.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{warning.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-yellow-400 font-bold">{f.kolicina}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* OK */}
{(() => {
const ok = filaments.filter(f => f.kolicina > 2);
return (
<div>
<h4 className="text-sm font-medium text-green-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500 inline-block" />
Dobro stanje ({ok.length})
</h4>
<p className="text-sm text-white/30">{ok.length} filamenata ima dovoljno na stanju (3+)</p>
</div>
);
})()}
{/* Product restock */}
{(() => {
const lowProducts = products.filter(p => p.stock <= 2);
return lowProducts.length > 0 ? (
<div className="mt-6">
<h4 className="text-sm font-medium text-orange-400 mb-2">Proizvodi za nabavku ({lowProducts.length})</h4>
<div className="space-y-1">
{lowProducts.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="text-white/60">{p.name}</span>
<span className={p.stock === 0 ? 'text-red-400 font-bold' : 'text-yellow-400'}>
{p.stock}
</span>
</div>
))}
</div>
</div>
) : null;
})()}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,448 @@
'use client'
import { useState, useEffect } from 'react';
import { colorService } from '@/src/services/api';
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
import '@/src/styles/select.css';
interface Color {
id: string;
name: string;
hex: string;
cena_refill?: number;
cena_spulna?: number;
createdAt?: string;
updatedAt?: string;
}
export default function BojePage() {
const [colors, setColors] = useState<Color[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingColor, setEditingColor] = useState<Color | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedColors, setSelectedColors] = useState<Set<string>>(new Set());
// Fetch colors
const fetchColors = async () => {
try {
setLoading(true);
const colors = await colorService.getAll();
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
} catch (err) {
setError('Greska pri ucitavanju boja');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchColors();
}, []);
const handleSave = async (color: Partial<Color>) => {
try {
if (color.id) {
await colorService.update(color.id, {
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
} else {
await colorService.create({
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
}
setEditingColor(null);
setShowAddForm(false);
fetchColors();
} catch (err) {
setError('Greska pri cuvanju boje');
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovu boju?')) {
return;
}
try {
await colorService.delete(id);
fetchColors();
} catch (err) {
setError('Greska pri brisanju boje');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedColors.size === 0) {
setError('Molimo izaberite boje za brisanje');
return;
}
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedColors.size} boja?`)) {
return;
}
try {
await Promise.all(Array.from(selectedColors).map(id => colorService.delete(id)));
setSelectedColors(new Set());
fetchColors();
} catch (err) {
setError('Greska pri brisanju boja');
console.error('Bulk delete error:', err);
}
};
const toggleColorSelection = (colorId: string) => {
const newSelection = new Set(selectedColors);
if (newSelection.has(colorId)) {
newSelection.delete(colorId);
} else {
newSelection.add(colorId);
}
setSelectedColors(newSelection);
};
const toggleSelectAll = () => {
const filteredColors = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
const allFilteredSelected = filteredColors.every(c => selectedColors.has(c.id));
if (allFilteredSelected) {
setSelectedColors(new Set());
} else {
setSelectedColors(new Set(filteredColors.map(c => c.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Upravljanje bojama</h1>
<p className="text-white/40 mt-1">{colors.length} boja ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingColor && (
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Dodaj novu boju
</button>
)}
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
{selectedColors.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Obrisi izabrane ({selectedColors.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Add Form */}
{showAddForm && (
<ColorForm
color={{}}
onSave={handleSave}
onCancel={() => {
setShowAddForm(false);
}}
/>
)}
{/* Search Bar */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi boje..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Colors Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={(() => {
const filtered = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
})()}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((color) => (
<tr key={color.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedColors.has(color.id)}
onChange={() => toggleColorSelection(color.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-10 h-10 rounded border-2 border-white/[0.08]"
style={{ backgroundColor: color.hex }}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.hex}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-400">
{(color.cena_refill || 3499).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-blue-400">
{(color.cena_spulna || 3999).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setEditingColor(color)}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(color.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Edit Modal */}
{editingColor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white/[0.04] rounded-2xl shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<h3 className="text-xl font-bold mb-4 text-white">Izmeni boju</h3>
<ColorForm
color={editingColor}
onSave={(color) => {
handleSave(color);
setEditingColor(null);
}}
onCancel={() => setEditingColor(null)}
isModal={true}
/>
</div>
</div>
</div>
)}
</div>
);
}
// Color Form Component
function ColorForm({
color,
onSave,
onCancel,
isModal = false
}: {
color: Partial<Color>,
onSave: (color: Partial<Color>) => void,
onCancel: () => void,
isModal?: boolean
}) {
const [formData, setFormData] = useState({
name: color.name || '',
hex: color.hex || '#000000',
cena_refill: color.cena_refill || 3499,
cena_spulna: color.cena_spulna || 3999,
});
const isBambuLabColor = !!(formData.name && Object.prototype.hasOwnProperty.call(bambuLabColors, formData.name));
const bambuHex = formData.name ? getColorHex(formData.name) : null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
setFormData({
...formData,
[name]: type === 'number' ? parseInt(value) || 0 : value
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const hexToSave = isBambuLabColor && bambuHex ? bambuHex : formData.hex;
onSave({
...color,
name: formData.name,
hex: hexToSave,
cena_refill: formData.cena_refill,
cena_spulna: formData.cena_spulna
});
};
return (
<div className={isModal ? "" : "p-6 bg-white/[0.04] rounded-2xl shadow"}>
{!isModal && (
<h2 className="text-xl font-bold mb-4 text-white">
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
</h2>
)}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Naziv boje</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="npr. Crvena"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Hex kod boje</label>
<div className="flex gap-2 items-center">
<input
type="color"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
disabled={isBambuLabColor}
className={`w-12 h-12 p-1 border-2 border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
/>
<input
type="text"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
required
readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000"
className={`flex-1 px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-[#060a14] cursor-not-allowed' : ''}`}
/>
</div>
{isBambuLabColor && (
<p className="text-xs text-white/40 mt-1">
Bambu Lab predefinisana boja - hex kod se ne moze menjati
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Refil</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3499"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Spulna</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3999"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,990 @@
'use client'
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { filamentService, colorService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { SaleManager } from '@/src/components/SaleManager';
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
import '@/src/styles/select.css';
// Colors that only come as refills (no spools)
const REFILL_ONLY_COLORS = [
'Beige',
'Light Gray',
'Yellow',
'Orange',
'Gold',
'Bright Green',
'Pink',
'Magenta',
'Maroon Red',
'Purple',
'Turquoise',
'Cobalt Blue',
'Brown',
'Bronze',
'Silver',
'Blue Grey',
'Dark Gray'
];
// Helper function to check if a filament is spool-only
const isSpoolOnly = (finish?: string, type?: string): boolean => {
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || finish === 'Wood' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
};
// Helper function to check if a filament should be refill-only
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
// If the finish/type combination is spool-only, then it's not refill-only
if (isSpoolOnly(finish, type)) {
return false;
}
// Translucent finish always has spool option
if (finish === 'Translucent') {
return false;
}
// Specific type/finish/color combinations that are refill-only
if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) {
return true;
}
if (type === 'TPU' && finish === '95A HF') {
return true;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
// Galaxy and Basic colors have spools available (not refill-only)
if (finish === 'Galaxy' || finish === 'Basic') {
return false;
}
return REFILL_ONLY_COLORS.includes(color);
};
// Helper function to filter colors based on material and finish
const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => {
// PPA CF only has black color
if (type === 'PPA' && finish === 'CF') {
return colors.filter(color => color.name.toLowerCase() === 'black');
}
return colors;
};
interface FilamentWithId extends Filament {
id: string;
createdAt?: string;
updatedAt?: string;
boja_hex?: string;
}
// Finish options by filament type
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
'ABS': ['GF', 'Bez Finisha'],
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Tough+', 'Translucent', 'Wood'],
'TPU': ['85A', '90A', '95A HF'],
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
'PC': ['CF', 'FR', 'Bez Finisha'],
'ASA': ['Bez Finisha'],
'PA': ['CF', 'GF', 'Bez Finisha'],
'PA6': ['CF', 'GF'],
'PAHT': ['CF', 'Bez Finisha'],
'PPA': ['CF'],
'PVA': ['Bez Finisha'],
'HIPS': ['Bez Finisha']
};
export default function FilamentiPage() {
const router = useRouter();
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [sortField, setSortField] = useState<string>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedFilaments, setSelectedFilaments] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>>([]);
// Fetch filaments
const fetchFilaments = async () => {
try {
setLoading(true);
const filaments = await filamentService.getAll();
setFilaments(filaments);
} catch (err) {
setError('Greska pri ucitavanju filamenata');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
const fetchAllData = async () => {
// Fetch both filaments and colors
await fetchFilaments();
try {
const colors = await colorService.getAll();
setAvailableColors(colors.sort((a: any, b: any) => a.name.localeCompare(b.name)));
} catch (error) {
console.error('Error loading colors:', error);
}
};
useEffect(() => {
fetchAllData();
}, []);
// Sorting logic
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Filter and sort filaments
const filteredAndSortedFilaments = useMemo(() => {
// First, filter by search term
let filtered = filaments;
if (searchTerm) {
const search = searchTerm.toLowerCase();
filtered = filaments.filter(f =>
f.tip?.toLowerCase().includes(search) ||
f.finish?.toLowerCase().includes(search) ||
f.boja?.toLowerCase().includes(search) ||
f.cena?.toLowerCase().includes(search)
);
}
// Then sort if needed
if (!sortField) return filtered;
return [...filtered].sort((a, b) => {
let aVal = a[sortField as keyof FilamentWithId];
let bVal = b[sortField as keyof FilamentWithId];
// Handle null/undefined values
if (aVal === null || aVal === undefined) aVal = '';
if (bVal === null || bVal === undefined) bVal = '';
// Handle date fields
if (sortField === 'created_at' || sortField === 'updated_at') {
const aDate = new Date(String(aVal));
const bDate = new Date(String(bVal));
return sortOrder === 'asc' ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
}
// Handle numeric fields
if (sortField === 'kolicina' || sortField === 'refill' || sortField === 'spulna') {
const aNum = Number(aVal) || 0;
const bNum = Number(bVal) || 0;
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
}
// String comparison for other fields
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [filaments, sortField, sortOrder, searchTerm]);
const handleSave = async (filament: Partial<FilamentWithId>) => {
try {
// Extract only the fields the API expects
const { id, ...dataForApi } = filament;
// Ensure numeric fields are numbers
const cleanData = {
tip: dataForApi.tip || 'PLA',
finish: dataForApi.finish || 'Basic',
boja: dataForApi.boja || '',
boja_hex: dataForApi.boja_hex || '#000000',
refill: Number(dataForApi.refill) || 0,
spulna: Number(dataForApi.spulna) || 0,
cena: dataForApi.cena || '3499'
};
// Validate required fields
if (!cleanData.tip || !cleanData.finish || !cleanData.boja) {
setError('Tip, Finish, and Boja are required fields');
return;
}
if (id) {
await filamentService.update(id, cleanData);
trackEvent('Admin', 'Update Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
} else {
await filamentService.create(cleanData);
trackEvent('Admin', 'Create Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
}
setEditingFilament(null);
setShowAddForm(false);
fetchAllData();
} catch (err: any) {
if (err.response?.status === 401 || err.response?.status === 403) {
setError('Sesija je istekla. Molimo prijavite se ponovo.');
setTimeout(() => {
router.push('/upadaj');
}, 2000);
} else {
// Extract error message properly
let errorMessage = 'Greska pri cuvanju filamenata';
if (err.response?.data?.error) {
errorMessage = err.response.data.error;
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (typeof err.response?.data === 'string') {
errorMessage = err.response.data;
} else if (err.message) {
errorMessage = err.message;
}
setError(errorMessage);
console.error('Save error:', err.response?.data || err.message);
}
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovaj filament?')) {
return;
}
try {
await filamentService.delete(id);
fetchAllData();
} catch (err) {
setError('Greska pri brisanju filamenata');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedFilaments.size === 0) {
setError('Molimo izaberite filamente za brisanje');
return;
}
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedFilaments.size} filamenata?`)) {
return;
}
try {
// Delete all selected filaments
await Promise.all(Array.from(selectedFilaments).map(id => filamentService.delete(id)));
setSelectedFilaments(new Set());
fetchAllData();
} catch (err) {
setError('Greska pri brisanju filamenata');
console.error('Bulk delete error:', err);
}
};
const toggleFilamentSelection = (filamentId: string) => {
const newSelection = new Set(selectedFilaments);
if (newSelection.has(filamentId)) {
newSelection.delete(filamentId);
} else {
newSelection.add(filamentId);
}
setSelectedFilaments(newSelection);
};
const toggleSelectAll = () => {
if (selectedFilaments.size === filteredAndSortedFilaments.length) {
setSelectedFilaments(new Set());
} else {
setSelectedFilaments(new Set(filteredAndSortedFilaments.map(f => f.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Filamenti</h1>
<p className="text-white/40 mt-1">{filaments.length} filamenata ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingFilament && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-3 sm:px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm sm:text-base"
>
Dodaj novi
</button>
)}
{selectedFilaments.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-3 sm:px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm sm:text-base"
>
Obrisi izabrane ({selectedFilaments.size})
</button>
)}
<SaleManager
filaments={filaments}
selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments}
/>
<BulkFilamentPriceEditor
filaments={filaments}
onUpdate={fetchFilaments}
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search Bar and Sorting */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi po tipu, finishu, boji ili ceni..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Sort Dropdown */}
<div>
<select
value={`${sortField || 'boja'}-${sortOrder || 'asc'}`}
onChange={(e) => {
try {
const [field, order] = e.target.value.split('-');
if (field && order) {
setSortField(field);
setSortOrder(order as 'asc' | 'desc');
}
} catch (error) {
console.error('Sort change error:', error);
}
}}
className="custom-select w-full px-3 py-2 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-md focus:outline-none focus:border-blue-500"
>
<option value="boja-asc">Sortiraj po: Boja (A-Z)</option>
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finis (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finis (Z-A)</option>
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
<option value="updated_at-desc">Sortiraj po: Poslednje azurirano</option>
<option value="updated_at-asc">Sortiraj po: Prvo azurirano</option>
<option value="kolicina-desc">Sortiraj po: Kolicina (visoka-niska)</option>
<option value="kolicina-asc">Sortiraj po: Kolicina (niska-visoka)</option>
</select>
</div>
</div>
{/* Add/Edit Form */}
{(showAddForm || editingFilament) && (
<div>
<FilamentForm
key={editingFilament?.id || 'new'}
filament={editingFilament || {}}
filaments={filaments}
availableColors={availableColors}
onSave={handleSave}
onCancel={() => {
setEditingFilament(null);
setShowAddForm(false);
}}
/>
</div>
)}
{/* Filaments Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSortedFilaments.length > 0 && selectedFilaments.size === filteredAndSortedFilaments.length}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th onClick={() => handleSort('tip')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Finis {sortField === 'finish' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Kolicina {sortField === 'kolicina' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Popust</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{filteredAndSortedFilaments.map((filament) => (
<tr key={filament.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedFilaments.has(filament.id)}
onChange={() => toggleFilamentSelection(filament.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.tip}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.finish}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
<div className="flex items-center gap-2">
{filament.boja_hex && (
<div
className="w-7 h-7 rounded border border-white/[0.08]"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span>{filament.boja}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.refill > 0 ? (
<span className="text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.spulna > 0 ? (
<span className="text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.kolicina}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-white/90">
{(() => {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return '-';
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
const colorData = availableColors.find(c => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
return (
<>
{hasRefill && (
<span className="text-green-400">
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className="text-blue-400">
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-white/40">RSD</span>
</>
);
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{filament.sale_active && filament.sale_percentage ? (
<div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{filament.sale_percentage}%
</span>
{filament.sale_end_date && (
<div className="text-xs text-white/40 mt-1">
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
</div>
)}
</div>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => {
setEditingFilament(filament);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(filament.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Filament Form Component
function FilamentForm({
filament,
filaments,
availableColors,
onSave,
onCancel
}: {
filament: Partial<FilamentWithId>,
filaments: FilamentWithId[],
availableColors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
onSave: (filament: Partial<FilamentWithId>) => void,
onCancel: () => void
}) {
const [formData, setFormData] = useState({
tip: filament.tip || (filament.id ? '' : 'PLA'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0),
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0),
kolicina: filament.kolicina || 0,
cena: '',
cena_refill: 0,
cena_spulna: 0,
});
// Track if this is the initial load to prevent price override
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Update form when filament prop changes
useEffect(() => {
let refillPrice = 0;
let spulnaPrice = 0;
if (filament.cena) {
const prices = filament.cena.split('/');
refillPrice = parseInt(prices[0]) || 0;
spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0;
}
const colorData = availableColors.find(c => c.name === filament.boja);
if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill;
if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna;
setFormData({
tip: filament.tip || (filament.id ? '' : 'PLA'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: filament.refill || 0,
spulna: filament.spulna || 0,
kolicina: filament.kolicina || 0,
cena: filament.cena || '',
cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999,
});
setIsInitialLoad(true);
}, [filament]);
// Update prices when color selection changes (but not on initial load)
useEffect(() => {
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
if (formData.boja && availableColors.length > 0) {
const colorData = availableColors.find(c => c.name === formData.boja);
if (colorData) {
setFormData(prev => ({
...prev,
cena_refill: colorData.cena_refill || prev.cena_refill,
cena_spulna: colorData.cena_spulna || prev.cena_spulna,
}));
}
}
}, [formData.boja, availableColors.length]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'refill' || name === 'spulna' || name === 'cena_refill' || name === 'cena_spulna') {
const numValue = parseInt(value) || 0;
setFormData({
...formData,
[name]: numValue
});
} else if (name === 'tip') {
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
const resetFinish = !newTypeFinishes.includes(formData.finish);
const spoolOnly = isSpoolOnly(formData.finish, value);
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
setFormData({
...formData,
[name]: value,
...(resetFinish ? { finish: '' } : {}),
...(spoolOnly ? { refill: 0 } : {}),
...(needsColorReset ? { boja: '' } : {})
});
} else if (name === 'boja') {
const refillOnly = isRefillOnly(value, formData.finish, formData.tip);
setFormData({
...formData,
[name]: value,
...(refillOnly ? { spulna: 0 } : {})
});
} else if (name === 'finish') {
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
const spoolOnly = isSpoolOnly(value, formData.tip);
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
setFormData({
...formData,
[name]: value,
...(refillOnly ? { spulna: 0 } : {}),
...(spoolOnly ? { refill: 0 } : {}),
...(needsColorReset ? { boja: '' } : {})
});
} else {
setFormData({
...formData,
[name]: value
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const totalQuantity = formData.refill + formData.spulna;
if (totalQuantity === 0) {
alert('Kolicina mora biti veca od 0. Dodajte refill ili spulna.');
return;
}
const refillPrice = formData.cena_refill;
const spoolPrice = formData.cena_spulna;
let priceString = '';
if (formData.refill > 0 && formData.spulna > 0) {
priceString = `${refillPrice}/${spoolPrice}`;
} else if (formData.refill > 0) {
priceString = String(refillPrice);
} else if (formData.spulna > 0) {
priceString = String(spoolPrice);
} else {
priceString = '3499/3999';
}
const dataToSave = {
id: filament.id,
tip: formData.tip,
finish: formData.finish,
boja: formData.boja,
boja_hex: formData.boja_hex,
refill: formData.refill,
spulna: formData.spulna,
cena: priceString
};
onSave(dataToSave);
};
return (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Tip</label>
<select
name="tip"
value={formData.tip}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi tip</option>
<option value="ABS">ABS</option>
<option value="ASA">ASA</option>
<option value="PA6">PA6</option>
<option value="PAHT">PAHT</option>
<option value="PC">PC</option>
<option value="PET">PET</option>
<option value="PETG">PETG</option>
<option value="PLA">PLA</option>
<option value="PPA">PPA</option>
<option value="PPS">PPS</option>
<option value="TPU">TPU</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Finis</label>
<select
name="finish"
value={formData.finish}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi finis</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Boja</label>
<select
name="boja"
value={formData.boja}
onChange={(e) => {
const selectedColorName = e.target.value;
let hexValue = formData.boja_hex;
const dbColor = availableColors.find(c => c.name === selectedColorName);
if (dbColor) {
hexValue = dbColor.hex;
}
handleChange({
target: {
name: 'boja',
value: selectedColorName
}
} as any);
setFormData(prev => ({
...prev,
boja_hex: hexValue,
cena_refill: dbColor?.cena_refill || prev.cena_refill || 3499,
cena_spulna: dbColor?.cena_spulna || prev.cena_spulna || 3999
}));
}}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberite boju</option>
{getFilteredColors(availableColors, formData.tip, formData.finish).map(color => (
<option key={color.id} value={color.name}>
{color.name}
</option>
))}
<option value="custom">Druga boja...</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
{formData.boja && formData.boja !== 'custom' ? `Hex kod za ${formData.boja}` : 'Hex kod boje'}
</label>
<div className="flex items-center gap-2">
<input
type="color"
name="boja_hex"
value={formData.boja_hex || '#000000'}
onChange={handleChange}
disabled={false}
className="w-full h-10 px-1 py-1 border border-white/[0.08] rounded-md bg-white/[0.06] cursor-pointer"
/>
{formData.boja_hex && (
<span className="text-sm text-white/40">{formData.boja_hex}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-green-400">Cena Refila</span>
</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill || availableColors.find(c => c.name === formData.boja)?.cena_refill || 3499}
onChange={handleChange}
min="0"
step="1"
placeholder="3499"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-blue-400">Cena Spulne</span>
</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna || availableColors.find(c => c.name === formData.boja)?.cena_spulna || 3999}
onChange={handleChange}
min="0"
step="1"
placeholder="3999"
disabled={isRefillOnly(formData.boja, formData.finish)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish)
? 'bg-white/[0.08] cursor-not-allowed text-white/40'
: 'bg-white/[0.06] text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
}`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Refil
{isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo spulna postoji)</span>
)}
</label>
<input
type="number"
name="refill"
value={formData.refill}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Spulna
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo refil postoji)</span>
)}
</label>
<input
type="number"
name="spulna"
value={formData.spulna}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Ukupna kolicina</label>
<input
type="number"
name="kolicina"
value={formData.refill + formData.spulna}
readOnly
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.08] text-white/90 cursor-not-allowed"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { AdminLayout } from '@/src/components/layout/AdminLayout';
import { usePathname } from 'next/navigation';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return <AdminLayout currentPath={pathname}>{children}</AdminLayout>;
}

View File

@@ -0,0 +1,249 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { filamentService, productService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { Product } from '@/src/types/product';
interface StatsCard {
label: string;
value: number | string;
colorHex: string;
href?: string;
}
export default function AdminOverview() {
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [filamentData, productData] = await Promise.all([
filamentService.getAll(),
productService.getAll().catch(() => []),
]);
setFilaments(filamentData);
setProducts(productData);
} catch (error) {
console.error('Error loading overview data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const lowStockFilaments = filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0);
const outOfStockFilaments = filaments.filter(f => f.kolicina === 0);
const lowStockProducts = products.filter(p => p.stock <= 2 && p.stock > 0);
const outOfStockProducts = products.filter(p => p.stock === 0);
const activeSales = filaments.filter(f => f.sale_active).length + products.filter(p => p.sale_active).length;
const statsCards: StatsCard[] = [
{ label: 'Ukupno filamenata', value: filaments.length, colorHex: '#3b82f6', href: '/upadaj/dashboard/filamenti' },
{ label: 'Ukupno proizvoda', value: products.length, colorHex: '#22c55e', href: '/upadaj/dashboard/stampaci' },
{ label: 'Nisko stanje', value: lowStockFilaments.length + lowStockProducts.length, colorHex: '#f59e0b' },
{ label: 'Aktivni popusti', value: activeSales, colorHex: '#a855f7', href: '/upadaj/dashboard/prodaja' },
];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-white/40 text-sm">Ucitavanje...</p>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Page title */}
<div>
<h1
className="text-2xl font-black text-white tracking-tight"
style={{ fontFamily: 'var(--font-display)' }}
>
Pregled
</h1>
<p className="text-white/40 mt-1 text-sm">Brzi pregled stanja inventara</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statsCards.map((card) => {
const content = (
<div
key={card.label}
className="rounded-2xl p-5 text-white"
style={{
background: `linear-gradient(135deg, ${card.colorHex}, ${card.colorHex}cc)`,
boxShadow: `0 4px 20px ${card.colorHex}30`,
}}
>
<p className="text-sm font-semibold opacity-80">{card.label}</p>
<p
className="text-3xl font-black mt-1"
style={{ fontFamily: 'var(--font-display)' }}
>
{card.value}
</p>
</div>
);
return card.href ? (
<Link key={card.label} href={card.href} className="hover:scale-[1.02] transition-transform">
{content}
</Link>
) : (
<div key={card.label}>{content}</div>
);
})}
</div>
{/* Low Stock Alerts */}
{(lowStockFilaments.length > 0 || outOfStockFilaments.length > 0 || lowStockProducts.length > 0 || outOfStockProducts.length > 0) && (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Upozorenja o stanju
</h2>
{/* Out of stock */}
{(outOfStockFilaments.length > 0 || outOfStockProducts.length > 0) && (
<div className="mb-4">
<h3 className="text-sm font-semibold text-red-400 mb-2">
Nema na stanju ({outOfStockFilaments.length + outOfStockProducts.length})
</h3>
<div className="space-y-1.5">
{outOfStockFilaments.slice(0, 5).map(f => (
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
{f.tip} {f.finish} - {f.boja}
</div>
))}
{outOfStockProducts.slice(0, 5).map(p => (
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
{p.name}
</div>
))}
{(outOfStockFilaments.length + outOfStockProducts.length) > 10 && (
<p className="text-xs text-white/30">
...i jos {outOfStockFilaments.length + outOfStockProducts.length - 10}
</p>
)}
</div>
</div>
)}
{/* Low stock */}
{(lowStockFilaments.length > 0 || lowStockProducts.length > 0) && (
<div>
<h3 className="text-sm font-semibold text-amber-400 mb-2">
Nisko stanje ({lowStockFilaments.length + lowStockProducts.length})
</h3>
<div className="space-y-1.5">
{lowStockFilaments.slice(0, 5).map(f => (
<div key={f.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
{f.tip} {f.finish} - {f.boja} (kolicina: {f.kolicina})
</div>
))}
{lowStockProducts.slice(0, 5).map(p => (
<div key={p.id} className="text-sm text-white/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500 shrink-0" />
{p.name} (stanje: {p.stock})
</div>
))}
{(lowStockFilaments.length + lowStockProducts.length) > 10 && (
<p className="text-xs text-white/30">
...i jos {lowStockFilaments.length + lowStockProducts.length - 10}
</p>
)}
</div>
</div>
)}
</div>
)}
{/* Recent Activity */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Poslednje dodano
</h2>
<div className="space-y-2.5">
{[...filaments]
.sort((a, b) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 5)
.map(f => (
<div key={f.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
{f.boja_hex && (
<div
className="w-4 h-4 rounded-md border border-white/10"
style={{ backgroundColor: f.boja_hex }}
/>
)}
<span className="text-white/70">{f.tip} {f.finish} - {f.boja}</span>
</div>
<span className="text-white/30 text-xs">
{f.updated_at
? new Date(f.updated_at).toLocaleDateString('sr-RS')
: f.created_at
? new Date(f.created_at).toLocaleDateString('sr-RS')
: '-'}
</span>
</div>
))}
{filaments.length === 0 && (
<p className="text-white/30 text-sm">Nema filamenata</p>
)}
</div>
</div>
{/* Quick Actions */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<h2
className="text-lg font-bold text-white mb-4"
style={{ fontFamily: 'var(--font-display)' }}
>
Brze akcije
</h2>
<div className="flex flex-wrap gap-3">
{[
{ href: '/upadaj/dashboard/filamenti', label: 'Dodaj filament', color: '#3b82f6' },
{ href: '/upadaj/dashboard/stampaci', label: 'Dodaj proizvod', color: '#22c55e' },
{ href: '/upadaj/dashboard/prodaja', label: 'Upravljaj popustima', color: '#a855f7' },
{ href: '/upadaj/dashboard/boje', label: 'Upravljaj bojama', color: '#ec4899' },
{ href: '/upadaj/dashboard/analitika', label: 'Analitika', color: '#14b8a6' },
].map(action => (
<Link
key={action.href}
href={action.href}
className="px-4 py-2.5 text-white rounded-xl text-sm font-semibold hover:scale-[1.03] transition-transform"
style={{
background: action.color,
boxShadow: `0 2px 10px ${action.color}30`,
}}
>
{action.label}
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,360 @@
'use client';
import { useState, useEffect } from 'react';
import { filamentService, productService, analyticsService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { Product, SalesStats } from '@/src/types/product';
export default function ProdajaPage() {
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Filament sale form
const [filamentSalePercentage, setFilamentSalePercentage] = useState(10);
const [filamentSaleEndDate, setFilamentSaleEndDate] = useState('');
const [filamentSaleEnabled, setFilamentSaleEnabled] = useState(true);
// Product sale form
const [productSalePercentage, setProductSalePercentage] = useState(10);
const [productSaleEndDate, setProductSaleEndDate] = useState('');
const [productSaleEnabled, setProductSaleEnabled] = useState(true);
const [savingFilamentSale, setSavingFilamentSale] = useState(false);
const [savingProductSale, setSavingProductSale] = useState(false);
const fetchData = async () => {
try {
setLoading(true);
const [filamentData, productData, salesData] = await Promise.all([
filamentService.getAll(),
productService.getAll().catch(() => []),
analyticsService.getSales().catch(() => null),
]);
setFilaments(filamentData);
setProducts(productData);
setSalesStats(salesData);
} catch (err) {
setError('Greska pri ucitavanju podataka');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const getCurrentDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
const handleFilamentBulkSale = async () => {
if (!confirm('Primeniti popust na SVE filamente?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: filamentSalePercentage,
saleEndDate: filamentSaleEndDate || undefined,
enableSale: filamentSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za filamente');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleClearFilamentSales = async () => {
if (!confirm('Ukloniti SVE popuste sa filamenata?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleProductBulkSale = async () => {
if (!confirm('Primeniti popust na SVE proizvode?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: productSalePercentage,
saleEndDate: productSaleEndDate || undefined,
enableSale: productSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za proizvode');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const handleClearProductSales = async () => {
if (!confirm('Ukloniti SVE popuste sa proizvoda?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const activeFilamentSales = filaments.filter(f => f.sale_active);
const activeProductSales = products.filter(p => p.sale_active);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Upravljanje popustima</h1>
<p className="text-white/40 mt-1">
{activeFilamentSales.length + activeProductSales.length} aktivnih popusta
</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Overview Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Filamenti sa popustom</p>
<p className="text-3xl font-bold text-purple-400 mt-1">{activeFilamentSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {filaments.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Proizvodi sa popustom</p>
<p className="text-3xl font-bold text-orange-400 mt-1">{activeProductSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {products.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Ukupno aktivnih</p>
<p className="text-3xl font-bold text-blue-400 mt-1">
{activeFilamentSales.length + activeProductSales.length}
</p>
</div>
</div>
{/* Filament Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na filamente</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={filamentSalePercentage}
onChange={(e) => setFilamentSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={filamentSaleEndDate}
onChange={(e) => setFilamentSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filamentSaleEnabled}
onChange={(e) => setFilamentSaleEnabled(e.target.checked)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={filamentSaleEnabled ? 'text-green-400' : 'text-white/30'}>{filamentSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleFilamentBulkSale}
disabled={savingFilamentSale}
className="px-4 py-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50 text-sm"
>
{savingFilamentSale ? 'Cuvanje...' : 'Primeni na sve filamente'}
</button>
<button
onClick={handleClearFilamentSales}
disabled={savingFilamentSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Product Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na proizvode</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={productSalePercentage}
onChange={(e) => setProductSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={productSaleEndDate}
onChange={(e) => setProductSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={productSaleEnabled}
onChange={(e) => setProductSaleEnabled(e.target.checked)}
className="w-4 h-4 text-orange-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={productSaleEnabled ? 'text-green-400' : 'text-white/30'}>{productSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleProductBulkSale}
disabled={savingProductSale}
className="px-4 py-2 bg-orange-600 text-white rounded-xl hover:bg-orange-700 disabled:opacity-50 text-sm"
>
{savingProductSale ? 'Cuvanje...' : 'Primeni na sve proizvode'}
</button>
<button
onClick={handleClearProductSales}
disabled={savingProductSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Active Sales List */}
{(activeFilamentSales.length > 0 || activeProductSales.length > 0) && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Aktivni popusti</h2>
{activeFilamentSales.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-purple-400 mb-2">Filamenti ({activeFilamentSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Filament</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeFilamentSales.map(f => (
<tr key={f.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{f.tip} {f.finish} - {f.boja}</td>
<td className="px-3 py-2">
<span className="text-purple-300 font-medium">-{f.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{f.sale_end_date
? new Date(f.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeProductSales.length > 0 && (
<div>
<h3 className="text-sm font-medium text-orange-400 mb-2">Proizvodi ({activeProductSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Proizvod</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeProductSales.map(p => (
<tr key={p.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{p.name}</td>
<td className="px-3 py-2">
<span className="text-orange-300 font-medium">-{p.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{p.sale_end_date
? new Date(p.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,334 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequest {
id: string;
color_name: string;
material_type: string;
finish_type: string;
user_email: string;
user_phone: string;
user_name: string;
description: string;
reference_url: string;
status: 'pending' | 'approved' | 'rejected' | 'completed';
admin_notes: string;
request_count: number;
created_at: string;
updated_at: string;
processed_at: string;
processed_by: string;
}
export default function ZahteviPage() {
const [requests, setRequests] = useState<ColorRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
try {
const data = await colorRequestService.getAll();
setRequests(data);
} catch (error) {
setError('Failed to fetch color requests');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleStatusUpdate = async (id: string) => {
try {
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
await fetchRequests();
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
} catch (error) {
setError('Failed to update request');
console.error('Error:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this request?')) return;
try {
await colorRequestService.delete(id);
await fetchRequests();
} catch (error) {
setError('Failed to delete request');
console.error('Error:', error);
}
};
const getStatusBadge = (status: string) => {
const colors = {
pending: 'bg-yellow-900/30 text-yellow-300',
approved: 'bg-green-900/30 text-green-300',
rejected: 'bg-red-900/30 text-red-300',
completed: 'bg-blue-900/30 text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-white/[0.06] text-white/60';
};
const getStatusLabel = (status: string) => {
const labels = {
pending: 'Na cekanju',
approved: 'Odobreno',
rejected: 'Odbijeno',
completed: 'Zavrseno'
};
return labels[status as keyof typeof labels] || status;
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
const date = new Date(dateString);
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-white">Zahtevi za Boje</h1>
<p className="text-white/40 mt-1">{requests.length} zahteva ukupno</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
<div className="bg-white/[0.04] rounded-2xl shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Materijal/Finis
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<div>
<div className="font-medium text-white/90">{request.color_name}</div>
{request.description && (
<div className="text-sm text-white/40 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
href={request.reference_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
Pogledaj referencu
</a>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<div className="text-white/90">{request.material_type}</div>
{request.finish_type && (
<div className="text-white/40">{request.finish_type}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-900/30 text-purple-300">
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
</span>
</td>
<td className="px-4 py-3">
<div className="text-sm">
{request.user_email ? (
<a href={`mailto:${request.user_email}`} className="text-blue-400 hover:underline">
{request.user_email}
</a>
) : (
<span className="text-white/30">Anonimno</span>
)}
{request.user_phone && (
<div className="mt-1">
<a href={`tel:${request.user_phone}`} className="text-blue-400 hover:underline">
{request.user_phone}
</a>
</div>
)}
</div>
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-y-2">
<select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="text-sm border rounded px-2 py-1 bg-white/[0.06] border-white/[0.08] text-white/90"
>
<option value="">Izaberi status</option>
<option value="pending">Na cekanju</option>
<option value="approved">Odobreno</option>
<option value="rejected">Odbijeno</option>
<option value="completed">Zavrseno</option>
</select>
<textarea
placeholder="Napomene..."
value={editForm.admin_notes}
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
className="text-sm border rounded px-2 py-1 w-full bg-white/[0.06] border-white/[0.08] text-white/90"
rows={2}
/>
</div>
) : (
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
{getStatusLabel(request.status)}
</span>
{request.admin_notes && (
<div className="text-xs text-white/30 mt-1">{request.admin_notes}</div>
)}
{request.processed_by && (
<div className="text-xs text-white/30 mt-1">
od {request.processed_by}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-white/40">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
<div className="text-xs text-white/30">
Obradjeno: {formatDate(request.processed_at)}
</div>
)}
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-x-2">
<button
onClick={() => handleStatusUpdate(request.id)}
className="text-green-400 hover:text-green-300 text-sm"
>
Sacuvaj
</button>
<button
onClick={() => {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-white/40 hover:text-white/60 text-sm"
>
Otkazi
</button>
</div>
) : (
<div className="space-x-2">
<button
onClick={() => {
setEditingId(request.id);
setEditForm({
status: request.status,
admin_notes: request.admin_notes || ''
});
}}
className="text-blue-400 hover:text-blue-300 text-sm"
>
Izmeni
</button>
<button
onClick={() => handleDelete(request.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Obrisi
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-white/40">
Nema zahteva za boje
</div>
)}
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-white/90">
{requests.length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Na Cekanju</div>
<div className="text-2xl font-bold text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Odobreno</div>
<div className="text-2xl font-bold text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Zavrseno</div>
<div className="text-2xl font-bold text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>
</div>
</div>
</div>
);
}

View File

@@ -33,7 +33,7 @@ export default function AdminLogin() {
trackEvent('Admin', 'Login', 'Success');
// Redirect to admin dashboard using window.location for static export
window.location.href = '/dashboard';
window.location.href = '/upadaj/dashboard';
} catch (err: any) {
setError('Neispravno korisničko ime ili lozinka');
console.error('Login error:', err);
@@ -44,7 +44,7 @@ export default function AdminLogin() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-[#060a14] py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="flex flex-col items-center">
<img
@@ -52,20 +52,23 @@ export default function AdminLogin() {
alt="Filamenteka"
className="h-40 w-auto mb-6 drop-shadow-lg"
/>
<h2 className="text-center text-3xl font-extrabold text-gray-900 dark:text-white">
<h2
className="text-center text-3xl font-black text-white tracking-tight"
style={{ fontFamily: 'var(--font-display)' }}
>
Admin Prijava
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
<p className="mt-2 text-center text-sm text-white/40">
Prijavite se za upravljanje filamentima
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
<div className="rounded-xl bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div className="space-y-3">
<div>
<label htmlFor="username" className="sr-only">
Korisničko ime
@@ -76,7 +79,7 @@ export default function AdminLogin() {
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
placeholder="Korisničko ime"
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -92,7 +95,7 @@ export default function AdminLogin() {
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
placeholder="Lozinka"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -104,7 +107,8 @@ export default function AdminLogin() {
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ boxShadow: '0 4px 14px rgba(59, 130, 246, 0.3)' }}
>
{loading ? 'Prijavljivanje...' : 'Prijavite se'}
</button>

View File

@@ -89,7 +89,7 @@ export default function ColorRequestsAdmin() {
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-white/[0.06] text-gray-800 dark:text-white/60';
};
const getStatusLabel = (status: string) => {
@@ -112,14 +112,14 @@ export default function ColorRequestsAdmin() {
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
<div className="text-gray-500 dark:text-white/40">Učitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14]">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
@@ -145,42 +145,42 @@ export default function ColorRequestsAdmin() {
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700">
<thead className="bg-gray-100 dark:bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Materijal/Finiš
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/40 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
<td className="px-4 py-3">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
{request.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
<div className="text-sm text-gray-500 dark:text-white/40 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
@@ -198,7 +198,7 @@ export default function ColorRequestsAdmin() {
<div className="text-sm">
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
{request.finish_type && (
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
<div className="text-gray-500 dark:text-white/40">{request.finish_type}</div>
)}
</div>
</td>
@@ -264,7 +264,7 @@ export default function ColorRequestsAdmin() {
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-600 dark:text-gray-400">
<div className="text-sm text-gray-600 dark:text-white/40">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
@@ -287,7 +287,7 @@ export default function ColorRequestsAdmin() {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
className="text-gray-600 dark:text-white/40 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
>
Otkaži
</button>
@@ -322,33 +322,33 @@ export default function ColorRequestsAdmin() {
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center py-8 text-gray-500 dark:text-white/40">
Nema zahteva za boje
</div>
)}
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno Zahteva</div>
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{requests.length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Na Čekanju</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</div>
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Odobreno</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</div>
<div className="bg-white dark:bg-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Završeno</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>

View File

@@ -0,0 +1,208 @@
import type { Metadata } from 'next';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
export const metadata: Metadata = {
title: 'Uslovi Koriscenja',
description: 'Uslovi koriscenja sajta Filamenteka — informacije o privatnoj prodaji, proizvodima, cenama i pravima kupaca.',
alternates: {
canonical: 'https://filamenteka.rs/uslovi-koriscenja',
},
};
export default function UsloviKoriscenjaPage() {
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Uslovi Koriscenja' },
]} />
<article className="mt-6">
<h1
className="text-3xl sm:text-4xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Uslovi Koriscenja
</h1>
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
Poslednje azuriranje: Februar 2026
</p>
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
1. O sajtu
</h2>
<p>
Filamenteka (filamenteka.rs) je informativni katalog koji prikazuje ponudu
Bambu Lab opreme dostupne za privatnu prodaju. Sajt sluzi iskljucivo kao
prezentacija proizvoda i nije e-commerce prodavnica kupovina se ne vrsi
direktno preko ovog sajta.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
2. Privatna prodaja fizickog lica
</h2>
<p>
Filamenteka je privatna prodaja fizickog lica i nije registrovana prodavnica,
preduzetnik niti pravno lice. Ovo je kljucna pravna distinkcija koja znaci da
se na transakcije primenjuju pravila koja vaze za privatnu prodaju izmedju
fizickih lica, a ne pravila koja se odnose na potrosacku zastitu u kontekstu
registrovanih trgovaca.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
3. Proizvodi i stanje
</h2>
<p className="mb-3">
Proizvodi navedeni na sajtu su originalni Bambu Lab proizvodi. Stanje proizvoda
je oznaceno na sledecu nacin:
</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>
<strong style={{ color: 'var(--text-primary)' }}>Novi proizvodi</strong>
neotvoreno, originalno fabricko pakovanje. Proizvod nije koriscen niti otvaran.
</li>
<li>
<strong style={{ color: 'var(--text-primary)' }}>Korisceni proizvodi</strong>
stanje je opisano za svaki proizvod pojedinacno u opisu artikla.
</li>
</ul>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
4. Cene
</h2>
<p>
Sve cene prikazane na sajtu su informativnog karaktera i izrazene su u srpskim
dinarima (RSD). Cene se mogu promeniti bez prethodne najave. Aktuelna cena u
trenutku kupovine je ona koja je navedena u oglasu na KupujemProdajem platformi.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
5. Garancija
</h2>
<p>
S obzirom da se radi o privatnoj prodaji fizickog lica, na proizvode se ne daje
komercijalna garancija osim onoga sto je zakonski propisano za privatnu prodaju.
Svi proizvodi oznaceni kao novi su u neotvorenom fabrickom pakovanju, sto
potvrdjuje njihov originalni kvalitet.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
6. Nacin kupovine
</h2>
<p>
Sve transakcije se obavljaju iskljucivo putem platforme{' '}
<a
href="https://www.kupujemprodajem.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline underline-offset-4"
>
KupujemProdajem
</a>
. Na sve transakcije obavljene putem te platforme primenjuju se uslovi
koriscenja KupujemProdajem platforme, ukljucujuci njihov sistem zastite kupaca.
Filamenteka ne odgovara za uslove i pravila KupujemProdajem platforme.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
7. Intelektualna svojina
</h2>
<p>
Bambu Lab je zastiteni znak kompanije Bambu Lab. Filamenteka nije u vlasnistvu,
niti je ovlascena od strane, niti je na bilo koji nacin povezana sa kompanijom
Bambu Lab. Svi nazivi proizvoda, logotipi i zastitni znakovi su vlasnistvo
njihovih respektivnih vlasnika.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
8. Ogranicenje odgovornosti
</h2>
<p>
Informacije na ovom sajtu su predstavljene &quot;takve kakve jesu&quot;. Ulazemo
razumne napore da informacije budu tacne i azurne, ali ne garantujemo potpunu
tacnost, kompletnost ili aktuelnost sadrzaja. Filamenteka ne snosi odgovornost
za bilo kakvu stetu nastalu koriscenjem informacija sa ovog sajta.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
9. Izmene uslova koriscenja
</h2>
<p>
Zadrzavamo pravo da azuriramo ove uslove koriscenja u bilo kom trenutku.
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
izmene. Nastavak koriscenja sajta nakon objave izmena smatra se prihvatanjem
novih uslova.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
10. Kontakt
</h2>
<p>
Za sva pitanja u vezi sa ovim uslovima koriscenja, mozete nas kontaktirati:
</p>
<ul className="list-none space-y-1 mt-2">
<li>Sajt: filamenteka.rs</li>
<li>Telefon: +381 63 103 1048</li>
</ul>
</section>
</div>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -0,0 +1,32 @@
-- Migration: Create products table for non-filament product categories
-- This is purely additive - does not modify existing tables
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TYPE product_category AS ENUM ('printer', 'build_plate', 'nozzle', 'spare_part', 'accessory');
CREATE TYPE product_condition AS ENUM ('new', 'used_like_new', 'used_good', 'used_fair');
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category product_category NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
price INTEGER,
condition product_condition DEFAULT 'new',
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
sale_percentage INTEGER DEFAULT 0,
sale_active BOOLEAN DEFAULT FALSE,
sale_start_date TIMESTAMP WITH TIME ZONE,
sale_end_date TIMESTAMP WITH TIME ZONE,
attributes JSONB NOT NULL DEFAULT '{}',
image_url VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_slug ON products(slug);
CREATE INDEX idx_products_is_active ON products(is_active);
CREATE INDEX idx_products_category_active ON products(category, is_active);

View File

@@ -0,0 +1,25 @@
-- Migration: Create printer models and product compatibility junction table
CREATE TABLE printer_models (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
series VARCHAR(50) NOT NULL
);
CREATE TABLE product_printer_compatibility (
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
printer_model_id UUID REFERENCES printer_models(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, printer_model_id)
);
-- Seed printer models
INSERT INTO printer_models (name, series) VALUES
('A1 Mini', 'A'),
('A1', 'A'),
('P1P', 'P'),
('P1S', 'P'),
('X1C', 'X'),
('X1E', 'X'),
('H2D', 'H'),
('H2S', 'H'),
('P2S', 'P');

View File

@@ -0,0 +1,12 @@
-- Migration: Multi-image support for products
CREATE TABLE product_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
alt_text VARCHAR(255),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_product_images_product_id ON product_images(product_id);

8
public/robots.txt Normal file
View File

@@ -0,0 +1,8 @@
User-agent: *
Allow: /
Disallow: /upadaj
Disallow: /upadaj/
Disallow: /dashboard
Disallow: /dashboard/
Sitemap: https://filamenteka.rs/sitemap.xml

48
public/sitemap.xml Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://filamenteka.rs</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://filamenteka.rs/filamenti</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://filamenteka.rs/stampaci</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/ploce</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/mlaznice</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/delovi</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/oprema</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/politika-privatnosti</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://filamenteka.rs/uslovi-koriscenja</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View File

@@ -0,0 +1,165 @@
'use client';
import React from 'react';
import '@/src/styles/select.css';
import { trackEvent } from '@/src/components/MatomoAnalytics';
interface FilterConfigItem {
key: string;
label: string;
type: 'select' | 'range' | 'checkbox';
options?: string[];
}
interface CatalogFiltersProps {
filters: Record<string, string>;
onFilterChange: (filters: Record<string, string>) => void;
filterConfig: FilterConfigItem[];
dynamicOptions?: Record<string, string[]>;
}
export function CatalogFilters({
filters,
onFilterChange,
filterConfig,
dynamicOptions,
}: CatalogFiltersProps) {
const hasActiveFilters = Object.values(filters).some((v) => v !== '');
const getOptions = (config: FilterConfigItem): string[] => {
if (dynamicOptions && dynamicOptions[config.key]) {
return dynamicOptions[config.key];
}
return config.options || [];
};
const handleFilterChange = (key: string, value: string) => {
onFilterChange({ ...filters, [key]: value });
trackEvent('Filter', key, value || 'All');
};
const handleReset = () => {
const cleared: Record<string, string> = {};
filterConfig.forEach((f) => {
cleared[f.key] = '';
});
onFilterChange(cleared);
trackEvent('Filter', 'Reset', 'All Filters');
};
return (
<div
className="rounded-2xl border p-4 sm:p-5"
style={{
borderColor: 'var(--border-subtle)',
background: 'var(--surface-secondary)',
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4 max-w-4xl mx-auto">
{filterConfig.map((config) => {
if (config.type === 'select') {
const options = getOptions(config);
return (
<div key={config.key}>
<label
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
>
{config.label}
</label>
<select
value={filters[config.key] || ''}
onChange={(e) => handleFilterChange(config.key, e.target.value)}
className="custom-select w-full px-3 py-2.5 text-sm font-medium
border rounded-xl
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
transition-all duration-200"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
>
<option value="">Sve</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
if (config.type === 'checkbox') {
return (
<div key={config.key} className="flex items-end">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={filters[config.key] === 'true'}
onChange={(e) =>
handleFilterChange(config.key, e.target.checked ? 'true' : '')
}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600
text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
/>
<span className="text-sm font-medium group-hover:opacity-80 transition-opacity"
style={{ color: 'var(--text-primary)' }}>
{config.label}
</span>
</label>
</div>
);
}
if (config.type === 'range') {
return (
<div key={config.key}>
<label
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
>
{config.label}
</label>
<input
type="text"
placeholder="npr. 1000-5000"
value={filters[config.key] || ''}
onChange={(e) => handleFilterChange(config.key, e.target.value)}
className="w-full px-3 py-2.5 text-sm border rounded-xl
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
transition-all duration-200"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
/>
</div>
);
}
return null;
})}
</div>
{hasActiveFilters && (
<div className="mt-4 text-center">
<button
onClick={handleReset}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold
text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10
hover:bg-red-100 dark:hover:bg-red-500/20
rounded-xl transition-colors duration-200"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
Resetuj filtere
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,676 @@
'use client';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { Product } from '@/src/types/product';
import { Filament } from '@/src/types/filament';
import { CategoryConfig } from '@/src/config/categories';
import { CONDITION_LABELS } from '@/src/config/categories';
import { filamentService, productService, colorService } from '@/src/services/api';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
import { Badge } from '@/src/components/ui/Badge';
import { EmptyState } from '@/src/components/ui/EmptyState';
import { CatalogFilters } from './CatalogFilters';
import { ProductTable, Column } from './ProductTable';
import { ProductGrid } from './ProductGrid';
import { FilamentRow } from './FilamentRow';
import { FilamentCard } from './FilamentCard';
import '@/src/styles/select.css';
type ViewMode = 'table' | 'grid';
interface CatalogPageProps {
category: CategoryConfig;
seoContent?: React.ReactNode;
}
function CatalogSkeleton() {
return (
<div className="space-y-5 animate-pulse">
{/* Search skeleton */}
<div className="h-12 rounded-2xl" style={{ background: 'var(--surface-secondary)' }} />
{/* Filter skeleton */}
<div className="p-5 rounded-2xl" style={{ background: 'var(--surface-secondary)' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-10 rounded-xl" style={{ background: 'var(--border-color)' }} />
</div>
))}
</div>
</div>
{/* Table skeleton */}
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'var(--border-subtle)' }}>
<div className="h-12" style={{ background: 'var(--surface-secondary)' }} />
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-14 flex items-center px-5 gap-4" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-20 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-24 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-12 rounded-full" style={{ background: 'var(--border-color)' }} />
</div>
))}
</div>
</div>
);
}
function ViewToggle({
viewMode,
onToggle,
}: {
viewMode: ViewMode;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="inline-flex items-center gap-2 px-3.5 py-2 text-sm font-semibold rounded-xl
border transition-all duration-200 hover:shadow-sm active:scale-[0.97]"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-secondary)',
}}
title={viewMode === 'table' ? 'Prikazi kao kartice' : 'Prikazi kao tabelu'}
>
{viewMode === 'table' ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Kartice
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Tabela
</>
)}
</button>
);
}
const FILAMENT_COLUMNS: Column<Filament>[] = [
{
key: 'tip',
label: 'Tip',
sortable: true,
render: (f) => <span className="font-semibold">{f.tip}</span>,
},
{
key: 'finish',
label: 'Finis',
sortable: true,
render: (f) => <span>{f.finish}</span>,
},
{
key: 'boja',
label: 'Boja',
sortable: true,
render: (f) => (
<div className="flex items-center gap-2">
{f.boja_hex && (
<div
className="w-6 h-6 rounded-lg flex-shrink-0 shadow-sm"
style={{
backgroundColor: f.boja_hex,
border: '1px solid rgba(0,0,0,0.1)',
}}
title={f.boja_hex}
/>
)}
<span className="text-xs sm:text-sm font-medium">{f.boja}</span>
</div>
),
},
{
key: 'refill',
label: 'Refil',
sortable: true,
render: (f) =>
f.refill > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{f.refill}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>0</span>
),
},
{
key: 'spulna',
label: 'Spulna',
sortable: true,
render: (f) =>
f.spulna > 0 ? (
<span className="text-blue-500 dark:text-blue-400 font-bold">{f.spulna}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>0</span>
),
},
{
key: 'kolicina',
label: 'Kolicina',
sortable: true,
render: (f) => <span className="font-semibold">{f.kolicina}</span>,
},
];
function getProductColumns(): Column<Product>[] {
return [
{
key: 'name',
label: 'Naziv',
sortable: true,
render: (p) => (
<div className="flex items-center gap-3">
{p.image_url && (
<img
src={p.image_url}
alt={p.name}
className="w-10 h-10 rounded-lg object-cover"
loading="lazy"
/>
)}
<span className="font-semibold" style={{ fontFamily: 'var(--font-display)' }}>{p.name}</span>
</div>
),
},
{
key: 'condition',
label: 'Stanje',
sortable: true,
render: (p) => (
<Badge
variant={p.condition === 'new' ? 'success' : 'info'}
size="sm"
>
{CONDITION_LABELS[p.condition] || p.condition}
</Badge>
),
},
{
key: 'stock',
label: 'Na stanju',
sortable: true,
render: (p) =>
p.stock > 0 ? (
<Badge variant="success" size="sm">
{p.stock}
</Badge>
) : (
<Badge variant="danger" size="sm">
Nema
</Badge>
),
},
{
key: 'price',
label: 'Cena',
sortable: true,
render: (p) => (
<PriceDisplay
price={p.price}
saleActive={p.sale_active}
salePercentage={p.sale_percentage}
/>
),
},
];
}
export function CatalogPage({ category, seoContent }: CatalogPageProps) {
const isFilament = category.isFilament === true;
const [filaments, setFilaments] = useState<Filament[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [availableColors, setAvailableColors] = useState<
Array<{ id: string; name: string; hex: string; cena_refill?: number; cena_spulna?: number }>
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
category.filters.forEach((f) => {
initial[f.key] = '';
});
return initial;
});
const [sortField, setSortField] = useState<string>(isFilament ? 'boja' : 'name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [isDesktop, setIsDesktop] = useState(true);
useEffect(() => {
const checkDesktop = () => {
setIsDesktop(window.innerWidth >= 1024);
};
checkDesktop();
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
}, []);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
if (isFilament) {
const [filamentData, colorData] = await Promise.all([
filamentService.getAll(),
colorService.getAll(),
]);
setFilaments(filamentData);
setAvailableColors(colorData);
} else if (category.apiCategory) {
const data = await productService.getAll({
category: category.apiCategory,
});
setProducts(data);
}
} catch (err) {
console.error('Error fetching catalog data:', err);
setError('Greska pri ucitavanju podataka. Pokusajte ponovo.');
} finally {
setLoading(false);
}
};
fetchData();
}, [isFilament, category.apiCategory]);
const dynamicOptions = useMemo(() => {
if (!isFilament) return undefined;
const filamentsWithInventory = filaments.filter((f) => f.kolicina > 0);
return {
material: [...new Set(filamentsWithInventory.map((f) => f.tip))].sort(),
finish: [...new Set(filamentsWithInventory.map((f) => f.finish))].sort(),
color: [...new Set(filamentsWithInventory.map((f) => f.boja))].sort(),
};
}, [isFilament, filaments]);
const handleSort = useCallback(
(field: string) => {
if (sortField === field) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortOrder('asc');
}
trackEvent('Table', 'Sort', field);
},
[sortField]
);
const handleViewToggle = useCallback(() => {
const next = viewMode === 'table' ? 'grid' : 'table';
setViewMode(next);
trackEvent('View', 'Toggle', next);
}, [viewMode]);
const effectiveView = isDesktop ? viewMode : 'grid';
const filteredFilaments = useMemo(() => {
if (!isFilament) return [];
let filtered = filaments.filter((f) => {
if (f.kolicina === 0) return false;
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matches =
f.tip.toLowerCase().includes(term) ||
f.finish.toLowerCase().includes(term) ||
f.boja.toLowerCase().includes(term) ||
f.cena.toLowerCase().includes(term);
if (!matches) return false;
}
if (filters.material && f.tip !== filters.material) return false;
if (filters.finish && f.finish !== filters.finish) return false;
if (filters.color && f.boja !== filters.color) return false;
return true;
});
if (sortField) {
filtered.sort((a, b) => {
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
if (sortField === 'kolicina' || sortField === 'cena' || sortField === 'refill' || sortField === 'spulna') {
aVal = parseFloat(String(aVal)) || 0;
bVal = parseFloat(String(bVal)) || 0;
} else {
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [isFilament, filaments, searchTerm, filters, sortField, sortOrder]);
const filteredProducts = useMemo(() => {
if (isFilament) return [];
let filtered = products.filter((p) => {
if (p.is_active === false) return false;
if (searchTerm) {
const term = searchTerm.toLowerCase();
if (!p.name.toLowerCase().includes(term)) return false;
}
if (filters.condition && p.condition !== filters.condition) return false;
return true;
});
if (sortField) {
filtered.sort((a, b) => {
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
if (sortField === 'price' || sortField === 'stock') {
aVal = Number(aVal) || 0;
bVal = Number(bVal) || 0;
} else {
aVal = String(aVal || '').toLowerCase();
bVal = String(bVal || '').toLowerCase();
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [isFilament, products, searchTerm, filters, sortField, sortOrder]);
const totalCount = isFilament ? filteredFilaments.length : filteredProducts.length;
const filamentColumnsWithPrice = useMemo((): Column<Filament>[] => {
return [
...FILAMENT_COLUMNS,
{
key: 'cena',
label: 'Cena',
sortable: true,
render: (filament: Filament) => {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return <span>-</span>;
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
const colorData = availableColors.find((c) => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
return (
<span className="font-bold">
{hasRefill && (
<span className={saleActive ? 'line-through text-gray-400' : 'text-emerald-600 dark:text-emerald-400'}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-emerald-600 dark:text-emerald-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1" style={{ color: 'var(--text-muted)' }}>/</span>}
{hasSpool && (
<span className={saleActive ? 'line-through text-gray-400' : 'text-blue-500 dark:text-blue-400'}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 font-normal text-xs" style={{ color: 'var(--text-muted)' }}>RSD</span>
{saleActive && (
<span className="ml-2">
<Badge variant="sale" size="sm">
-{filament.sale_percentage}%
</Badge>
</span>
)}
</span>
);
},
},
];
}, [availableColors]);
const productColumns = useMemo(() => getProductColumns(), []);
const filterConfigWithLabels = useMemo(() => {
return category.filters.map((f) => {
if (f.key === 'condition' && f.options) {
return {
...f,
options: f.options.map((opt) => opt),
};
}
return f;
});
}, [category.filters]);
const conditionDynamicOptions = useMemo(() => {
if (isFilament) return undefined;
const opts: Record<string, string[]> = {};
category.filters.forEach((f) => {
if (f.key === 'condition' && f.options) {
opts[f.key] = f.options;
}
});
return Object.keys(opts).length > 0 ? opts : undefined;
}, [isFilament, category.filters]);
const displayFilterConfig = useMemo(() => {
return category.filters.map((f) => {
if (f.key === 'condition' && f.options) {
return {
...f,
options: f.options.map((opt) => CONDITION_LABELS[opt] || opt),
};
}
return f;
});
}, [category.filters]);
const handleFilterChange = useCallback(
(newFilters: Record<string, string>) => {
const mapped = { ...newFilters };
const conditionFilter = category.filters.find((f) => f.key === 'condition');
if (conditionFilter && mapped.condition) {
const conditionEntries = Object.entries(CONDITION_LABELS);
const entry = conditionEntries.find(([, label]) => label === mapped.condition);
if (entry) {
mapped.condition = entry[0];
}
}
setFilters(mapped);
},
[category.filters]
);
const displayFilters = useMemo(() => {
const mapped = { ...filters };
if (mapped.condition) {
mapped.condition = CONDITION_LABELS[mapped.condition] || mapped.condition;
}
return mapped;
}, [filters]);
if (loading) {
return (
<div className="space-y-6">
<CatalogSkeleton />
</div>
);
}
if (error) {
return (
<div
className="rounded-2xl border p-8 text-center"
style={{
borderColor: 'rgba(239,68,68,0.2)',
background: 'rgba(239,68,68,0.04)',
}}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-red-50 dark:bg-red-500/10 flex items-center justify-center">
<svg className="w-7 h-7 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<p className="font-semibold text-red-700 dark:text-red-300 mb-4" style={{ fontFamily: 'var(--font-display)' }}>
{error}
</p>
<button
onClick={() => window.location.reload()}
className="px-5 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600
rounded-xl transition-colors duration-200 active:scale-[0.97]"
>
Pokusaj ponovo
</button>
</div>
);
}
return (
<div className="space-y-5">
{/* Search bar */}
<div className="relative">
<input
type="text"
placeholder={isFilament ? 'Pretrazi po materijalu, boji...' : 'Pretrazi proizvode...'}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (e.target.value) {
trackEvent('Search', category.slug, e.target.value);
}
}}
className="w-full px-4 py-3 pl-11 text-sm font-medium border rounded-2xl
focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500
transition-all duration-200 placeholder:font-normal"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
/>
<svg
className="absolute left-3.5 top-3.5 h-4.5 w-4.5"
style={{ color: 'var(--text-muted)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Filters */}
<CatalogFilters
filters={isFilament ? filters : displayFilters}
onFilterChange={isFilament ? setFilters : handleFilterChange}
filterConfig={isFilament ? category.filters : displayFilterConfig}
dynamicOptions={isFilament ? dynamicOptions : conditionDynamicOptions}
/>
{/* Controls row */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>{totalCount}</span>{' '}
{isFilament ? 'filamenata' : 'proizvoda'}
</p>
<ViewToggle viewMode={effectiveView} onToggle={handleViewToggle} />
</div>
{/* Content */}
{totalCount === 0 ? (
<EmptyState
title="Nema rezultata"
description="Pokusajte da promenite filtere ili pretragu."
/>
) : isFilament ? (
effectiveView === 'table' ? (
<ProductTable<Filament>
items={filteredFilaments}
columns={filamentColumnsWithPrice}
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
) : (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{filteredFilaments.map((filament) => (
<FilamentCard key={filament.id} filament={filament} />
))}
</div>
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
Prikazano {filteredFilaments.length} filamenata
</p>
</div>
)
) : effectiveView === 'table' ? (
<ProductTable<Product>
items={filteredProducts}
columns={productColumns}
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
) : (
<ProductGrid
products={filteredProducts}
categoryColor={category.colorHex}
/>
)}
{/* SEO content slot */}
{seoContent && <div className="mt-8">{seoContent}</div>}
</div>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import React from 'react';
import { Filament } from '@/src/types/filament';
import { Badge } from '@/src/components/ui/Badge';
interface FilamentCardProps {
filament: Filament;
}
function getContrastColor(hex: string | undefined): 'light' | 'dark' {
if (!hex) return 'dark';
const cleanHex = hex.replace('#', '');
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'dark' : 'light';
}
export function FilamentCard({ filament }: FilamentCardProps) {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
const contrast = getContrastColor(filament.boja_hex);
const isLight = contrast === 'light';
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
const textClass = isLight ? 'text-gray-900' : 'text-white';
const textShadow = isLight ? 'none' : '0 1px 3px rgba(0,0,0,0.5)';
const subtextClass = isLight ? 'text-gray-700' : 'text-gray-100';
return (
<div
className="rounded-2xl overflow-hidden shadow-md
transition-all duration-300 hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1"
style={{
backgroundColor: filament.boja_hex || '#f3f4f6',
border: isLight ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.1)',
}}
>
{/* Top badges */}
<div className="p-4 pb-1">
<div className="flex items-center justify-between">
<Badge
variant={filament.tip === 'PLA' ? 'success' : filament.tip === 'PETG' ? 'info' : 'default'}
size="md"
>
{filament.tip}
</Badge>
{saleActive && (
<Badge variant="sale" size="sm">
-{filament.sale_percentage}%
</Badge>
)}
</div>
<div className={`mt-2 ${subtextClass}`} style={{ textShadow }}>
<span className="text-[11px] font-bold uppercase tracking-[0.12em]">
{filament.finish}
</span>
</div>
</div>
{/* Color name */}
<div className="px-4 py-3">
<h3
className={`text-xl font-black ${textClass}`}
style={{ textShadow, fontFamily: 'var(--font-display)', letterSpacing: '-0.02em' }}
>
{filament.boja}
</h3>
</div>
{/* Bottom info section */}
<div
className="px-4 py-3.5 space-y-2.5"
style={{
backgroundColor: isLight ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
>
{/* Stock counts */}
<div className="flex items-center gap-3">
{hasRefill && (
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-green-700' : 'text-green-300'}`}>
Refil
</span>
<span className={`text-sm font-black ${isLight ? 'text-green-800' : 'text-green-200'}`}>
{filament.refill}
</span>
</div>
)}
{hasSpool && (
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
Spulna
</span>
<span className={`text-sm font-black ${isLight ? 'text-blue-800' : 'text-blue-200'}`}>
{filament.spulna}
</span>
</div>
)}
<div className="flex items-center gap-1.5 ml-auto">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${subtextClass}`} style={{ textShadow }}>
Ukupno
</span>
<span className={`text-sm font-black ${textClass}`} style={{ textShadow }}>
{filament.kolicina}
</span>
</div>
</div>
{/* Pricing */}
<div className={`flex items-center gap-2 flex-wrap pt-2 ${isLight ? 'border-gray-300/60' : 'border-white/15'}`}
style={{ borderTop: `1px solid ${isLight ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.15)'}` }}>
{!hasRefill && !hasSpool ? (
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>-</span>
) : (
<>
{hasRefill && (
<>
{saleActive ? (
<>
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
{refillPrice.toLocaleString('sr-RS')}
</span>
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
</>
) : (
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
</>
)}
{hasRefill && hasSpool && (
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>/</span>
)}
{hasSpool && (
<>
{saleActive ? (
<>
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
</>
) : (
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
</>
)}
<span className={`text-[11px] font-semibold ${subtextClass} ml-0.5`} style={{ textShadow }}>RSD</span>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import React from 'react';
import { Filament } from '@/src/types/filament';
interface FilamentRowProps {
filament: Filament;
}
export function FilamentRow({ filament }: FilamentRowProps) {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
// Parse prices from the cena field (format: "3499" or "3499/3999")
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
return (
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.tip}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.finish}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1 sm:gap-2">
{filament.boja_hex && (
<div
className="w-5 h-5 sm:w-7 sm:h-7 rounded border border-gray-300 dark:border-gray-600 flex-shrink-0"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span className="text-xs sm:text-sm text-gray-900 dark:text-gray-100">
{filament.boja}
</span>
</div>
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
{hasRefill ? (
<span className="text-green-600 dark:text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
{hasSpool ? (
<span className="text-blue-500 dark:text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.kolicina}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">
{!hasRefill && !hasSpool ? (
'-'
) : (
<>
{hasRefill && (
<span
className={
saleActive
? 'line-through text-gray-500 dark:text-gray-400'
: 'text-green-600 dark:text-green-400'
}
>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-green-600 dark:text-green-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span
className={
saleActive
? 'line-through text-gray-500 dark:text-gray-400'
: 'text-blue-500 dark:text-blue-400'
}
>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
{saleActive && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
-{filament.sale_percentage}%
</span>
)}
</>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import { Product } from '@/src/types/product';
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
import { Badge } from '@/src/components/ui/Badge';
import { CONDITION_LABELS } from '@/src/config/categories';
interface ProductCardProps {
product: Product;
categoryColor?: string;
}
function getCategoryIcon(category: string): React.ReactNode {
switch (category) {
case 'printer':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
case 'build_plate':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6z" />
</svg>
);
case 'nozzle':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
default:
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
);
}
}
export function ProductCard({ product, categoryColor = '#3b82f6' }: ProductCardProps) {
const isOutOfStock = product.stock === 0;
const conditionLabel = CONDITION_LABELS[product.condition] || product.condition;
return (
<div
className="category-card rounded-2xl border overflow-hidden"
style={{
'--card-color': categoryColor,
borderColor: 'var(--border-subtle)',
background: 'var(--surface-elevated)',
} as React.CSSProperties}
>
{/* Category accent bar */}
<div className="h-1" style={{ background: `linear-gradient(90deg, ${categoryColor}, ${categoryColor}80)` }} />
{/* Image or placeholder */}
<div className="relative aspect-[4/3] flex items-center justify-center" style={{ background: 'var(--surface-secondary)' }}>
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
getCategoryIcon(product.category)
)}
{isOutOfStock && (
<div className="absolute top-3 right-3">
<Badge variant="danger" size="sm">Nema na stanju</Badge>
</div>
)}
</div>
{/* Content */}
<div className="p-4 sm:p-5 space-y-3">
<h3
className="font-bold line-clamp-2 min-h-[2.5rem]"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
{product.name}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={product.condition === 'new' ? 'success' : 'info'}
size="sm"
>
{conditionLabel}
</Badge>
{!isOutOfStock && (
<Badge variant="success" size="sm">
Na stanju ({product.stock})
</Badge>
)}
</div>
<div className="pt-2" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<PriceDisplay
price={product.price}
saleActive={product.sale_active}
salePercentage={product.sale_percentage}
className="font-bold"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { Product } from '@/src/types/product';
import { ProductCard } from './ProductCard';
interface ProductGridProps {
products: Product[];
categoryColor?: string;
renderCard?: (product: Product) => React.ReactNode;
}
export function ProductGrid({
products,
categoryColor,
renderCard,
}: ProductGridProps) {
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{products.map((product) =>
renderCard ? (
<React.Fragment key={product.id}>{renderCard(product)}</React.Fragment>
) : (
<ProductCard key={product.id} product={product} categoryColor={categoryColor} />
)
)}
</div>
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
Prikazano {products.length} proizvoda
</p>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
export interface Column<T> {
key: string;
label: string;
sortable?: boolean;
render?: (item: T) => React.ReactNode;
className?: string;
}
interface ProductTableProps<T> {
items: T[];
columns: Column<T>[];
sortField: string;
sortOrder: 'asc' | 'desc';
onSort: (field: string) => void;
}
function SortIndicator({ field, sortField, sortOrder }: { field: string; sortField: string; sortOrder: 'asc' | 'desc' }) {
if (field !== sortField) {
return (
<span className="ml-1.5 opacity-0 group-hover:opacity-60 transition-opacity text-[10px]">
</span>
);
}
return (
<span className="ml-1.5 text-blue-500 dark:text-blue-400 text-[10px]">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
);
}
export function ProductTable<T extends { id?: string }>({
items,
columns,
sortField,
sortOrder,
onSort,
}: ProductTableProps<T>) {
return (
<div
className="overflow-x-auto rounded-2xl border shadow-sm"
style={{ borderColor: 'var(--border-subtle)' }}
>
<table className="min-w-full">
<thead>
<tr style={{ background: 'var(--surface-secondary)' }}>
{columns.map((col) => (
<th
key={col.key}
onClick={col.sortable !== false ? () => onSort(col.key) : undefined}
className={`px-3 sm:px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.1em] group
${col.sortable !== false ? 'cursor-pointer select-none' : ''}
${col.className || ''}`}
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)' }}
>
<span className="inline-flex items-center">
{col.label}
{col.sortable !== false && (
<SortIndicator field={col.key} sortField={sortField} sortOrder={sortOrder} />
)}
</span>
</th>
))}
</tr>
</thead>
<tbody style={{ background: 'var(--surface-elevated)' }}>
{items.map((item, index) => (
<tr
key={item.id || index}
className="transition-colors duration-150 hover:bg-blue-50/50 dark:hover:bg-white/[0.03]"
style={{ borderTop: '1px solid var(--border-subtle)' }}
>
{columns.map((col) => (
<td
key={col.key}
className={`px-3 sm:px-5 py-3.5 whitespace-nowrap text-sm ${col.className || ''}`}
style={{ color: 'var(--text-primary)' }}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect } from 'react';
import { useAuth } from '@/src/hooks/useAuth';
import { AdminSidebar } from './AdminSidebar';
interface AdminLayoutProps {
children: React.ReactNode;
currentPath: string;
}
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
const { isAuthenticated, isLoading } = useAuth();
// Force dark mode for admin
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
if (isLoading) {
return (
<div className="min-h-screen bg-[#060a14] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-white/40 text-sm" style={{ fontFamily: 'var(--font-body)' }}>
Ucitavanje...
</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-[#060a14] flex">
<AdminSidebar currentPath={currentPath} />
<main className="flex-1 min-w-0 lg:ml-0">
{/* Top bar for mobile spacing (hamburger menu space) */}
<div className="lg:hidden h-14" />
<div className="p-4 sm:p-6 lg:p-8">
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,286 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { trackEvent } from '@/src/components/MatomoAnalytics';
interface AdminSidebarProps {
currentPath: string;
}
interface SidebarLink {
href: string;
label: string;
icon: React.ReactNode;
colorHex: string;
}
const sidebarLinks: SidebarLink[] = [
{
href: '/upadaj/dashboard',
label: 'Pregled',
colorHex: '#3b82f6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
href: '/upadaj/dashboard/filamenti',
label: 'Filamenti',
colorHex: '#3b82f6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
),
},
{
href: '/upadaj/dashboard/stampaci',
label: 'Stampaci',
colorHex: '#ef4444',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
),
},
{
href: '/upadaj/dashboard/ploce',
label: 'Ploce',
colorHex: '#22c55e',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
),
},
{
href: '/upadaj/dashboard/mlaznice',
label: 'Mlaznice',
colorHex: '#f59e0b',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/delovi',
label: 'Delovi',
colorHex: '#a855f7',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/oprema',
label: 'Oprema',
colorHex: '#06b6d4',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
href: '/upadaj/dashboard/boje',
label: 'Boje',
colorHex: '#ec4899',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
},
{
href: '/upadaj/dashboard/zahtevi',
label: 'Zahtevi',
colorHex: '#8b5cf6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
),
},
{
href: '/upadaj/dashboard/prodaja',
label: 'Prodaja',
colorHex: '#f97316',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/analitika',
label: 'Analitika',
colorHex: '#14b8a6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
];
export function AdminSidebar({ currentPath }: AdminSidebarProps) {
const [isOpen, setIsOpen] = useState(false);
const isActive = (href: string) => {
if (href === '/upadaj/dashboard' && currentPath === '/upadaj/dashboard') return true;
if (href !== '/upadaj/dashboard' && currentPath.startsWith(href)) return true;
return false;
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
trackEvent('Admin', 'Logout', 'Sidebar');
window.location.href = '/upadaj';
};
const sidebarContent = (
<div className="flex flex-col h-full">
{/* Logo */}
<div className="p-4 border-b border-white/[0.08]">
<Link href="/upadaj/dashboard" className="flex items-center gap-3">
<img
src="/logo.png"
alt="Filamenteka Admin"
loading="lazy"
decoding="async"
className="h-10 w-auto"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<div className="flex items-baseline gap-1.5">
<span
className="text-lg font-black tracking-tight text-white"
style={{ fontFamily: 'var(--font-display)' }}
>
Filament<span className="gradient-text">eka</span>
</span>
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-white/40">Admin</span>
</div>
</Link>
</div>
{/* Navigation links */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1" aria-label="Admin navigacija">
{sidebarLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={() => {
trackEvent('Admin', 'Sidebar Navigation', link.label);
setIsOpen(false);
}}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold
transition-all duration-200
${
active
? 'text-white shadow-lg'
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
}
`}
style={
active
? {
backgroundColor: link.colorHex,
boxShadow: `0 4px 12px ${link.colorHex}40`,
}
: undefined
}
>
{link.icon}
{link.label}
</Link>
);
})}
</nav>
{/* Bottom section */}
<div className="p-3 border-t border-white/[0.08] space-y-1">
{/* Back to site */}
<Link
href="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-white/50 hover:text-white hover:bg-white/[0.06] transition-all duration-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Nazad na sajt
</Link>
{/* Logout */}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all duration-200 w-full text-left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Odjava
</button>
</div>
</div>
);
return (
<>
{/* Mobile hamburger button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="lg:hidden fixed top-4 left-4 z-50 p-2.5 bg-slate-900 text-white rounded-xl shadow-lg hover:bg-slate-800 transition-colors border border-white/[0.08]"
aria-label={isOpen ? 'Zatvori meni' : 'Otvori meni'}
aria-expanded={isOpen}
>
{isOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
{/* Mobile overlay */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 z-40 h-full w-64 bg-[#0a0e1a] border-r border-white/[0.06]
transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:static lg:z-auto
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
aria-label="Admin bocna navigacija"
>
{sidebarContent}
</aside>
</>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import Link from 'next/link';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
...(item.href
? {
item: `https://filamenteka.rs${item.href}`,
}
: {}),
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav aria-label="Breadcrumb" className="py-3">
<ol className="flex items-center flex-wrap gap-1 text-sm" style={{ color: 'var(--text-muted)' }}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center">
{index > 0 && (
<svg
className="w-3.5 h-3.5 mx-1.5 flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
{isLast || !item.href ? (
<span
className={isLast ? 'font-semibold' : ''}
style={{ color: isLast ? 'var(--text-primary)' : 'var(--text-muted)' }}
aria-current={isLast ? 'page' : undefined}
>
{item.label}
</span>
) : (
<Link
href={item.href}
className="hover:underline underline-offset-4 transition-colors duration-200"
style={{ color: 'var(--text-secondary)' }}
>
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
</>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getEnabledCategories, CategoryConfig } from '@/src/config/categories';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
import { trackEvent } from '@/src/components/MatomoAnalytics';
// Darker color variants for readable text on light backgrounds
const darkTextMap: Record<string, string> = {
'#3b82f6': '#1d4ed8',
'#ef4444': '#b91c1c',
'#22c55e': '#15803d',
'#f59e0b': '#b45309',
'#a855f7': '#7e22ce',
'#06b6d4': '#0e7490',
};
interface CategoryNavProps {
currentSlug?: string;
}
export function CategoryNav({ currentSlug }: CategoryNavProps) {
const categories = getEnabledCategories();
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const check = () => setIsDark(document.documentElement.classList.contains('dark'));
check();
const observer = new MutationObserver(check);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return (
<nav
className="flex gap-2 overflow-x-auto scrollbar-hide py-1 px-0.5"
aria-label="Kategorije proizvoda"
>
{categories.map((category: CategoryConfig) => {
const isActive = currentSlug === category.slug;
const textColor = isDark ? category.colorHex : (darkTextMap[category.colorHex] || category.colorHex);
return (
<Link
key={category.slug}
href={`/${category.slug}`}
onClick={() => trackEvent('Navigation', 'Category Click', category.label)}
className={`
flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-bold
transition-all duration-250 whitespace-nowrap
${isActive
? 'text-white shadow-lg scale-[1.02]'
: 'hover:scale-[1.01]'
}
`}
style={isActive ? {
backgroundColor: category.colorHex,
boxShadow: `0 4px 20px -2px ${category.colorHex}60`,
fontFamily: 'var(--font-display)',
} : {
backgroundColor: `${category.colorHex}${isDark ? '12' : '10'}`,
color: textColor,
fontFamily: 'var(--font-display)',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '20' : '18'}`;
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '12' : '10'}`;
}
}}
aria-current={isActive ? 'page' : undefined}
>
<CategoryIcon slug={category.slug} className="w-4 h-4" />
<span>{category.labelShort}</span>
</Link>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import Link from 'next/link';
import { trackEvent } from '@/src/components/MatomoAnalytics';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
export function SiteFooter() {
return (
<footer className="relative mt-20">
{/* Bold category color accent strip */}
<div
className="h-1.5"
style={{
background: 'linear-gradient(90deg, #3b82f6 0%, #6366f1 10%, #ef4444 20%, #f97316 30%, #22c55e 42%, #10b981 52%, #f59e0b 62%, #a855f7 74%, #ec4899 84%, #06b6d4 92%, #3b82f6 100%)',
}}
/>
<div className="bg-gradient-to-b from-slate-900 to-slate-950 dark:from-[#0f172a] dark:to-[#0a0f1e]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-12 pb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-10 lg:gap-14">
{/* Brand */}
<div>
<Link href="/" className="inline-flex items-center gap-2.5 group mb-5">
<img
src="/logo.png"
alt="Filamenteka"
loading="lazy"
decoding="async"
className="h-9 w-auto opacity-85 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<span className="text-lg font-bold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
<span className="text-white">Filament</span>
<span className="gradient-text">eka</span>
</span>
</Link>
<p className="text-sm text-gray-300/80 leading-relaxed max-w-xs">
Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, stampaci, podloge, mlaznice i rezervni delovi.
</p>
</div>
{/* Links */}
<div>
<h3
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
style={{ fontFamily: 'var(--font-display)' }}
>
Navigacija
</h3>
<ul className="space-y-3">
<li>
<Link
href="/filamenti"
className="text-sm text-gray-400 hover:text-blue-400 transition-colors duration-300 font-medium"
>
Filamenti
</Link>
</li>
<li>
<Link
href="/stampaci"
className="text-sm text-gray-400 hover:text-red-400 transition-colors duration-300 font-medium"
>
3D Stampaci
</Link>
</li>
<li>
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer')}
className="text-sm text-gray-400 hover:text-emerald-400 transition-colors duration-300 inline-flex items-center gap-1.5 font-medium"
>
KupujemProdajem
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</li>
<li className="pt-2 border-t border-white/[0.08]">
<Link
href="/politika-privatnosti"
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
>
Politika privatnosti
</Link>
</li>
<li>
<Link
href="/uslovi-koriscenja"
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
>
Uslovi koriscenja
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
style={{ fontFamily: 'var(--font-display)' }}
>
Kontakt
</h3>
<div className="space-y-4">
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
className="flex items-center gap-3 text-gray-400 hover:text-blue-300 transition-all duration-300 group"
>
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-blue-500/20 group-hover:shadow-[0_0_12px_rgba(59,130,246,0.15)] transition-all duration-300">
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span className="text-sm font-medium">+381 63 103 1048</span>
</a>
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer Contact')}
className="flex items-center gap-3 text-gray-400 hover:text-emerald-300 transition-all duration-300 group"
>
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-emerald-500/20 group-hover:shadow-[0_0_12px_rgba(16,185,129,0.15)] transition-all duration-300">
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</div>
<span className="text-sm font-medium">KupujemProdajem profil</span>
</a>
</div>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 pt-6 border-t border-white/[0.08] flex flex-col sm:flex-row items-center justify-between gap-3">
<p className="text-xs text-gray-400">
&copy; {new Date().getFullYear()} <span className="font-semibold text-gray-300">Filamenteka</span>. Sva prava zadrzana.
</p>
<p className="text-xs text-gray-500 text-center sm:text-right max-w-sm">
Privatna prodaja fizickog lica, ne registrovana prodavnica.
</p>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useRef } from 'react';
import { getEnabledCategories } from '@/src/config/categories';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { CategoryNav } from './CategoryNav';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
interface SiteHeaderProps {
currentCategory?: string;
}
export function SiteHeader({ currentCategory }: SiteHeaderProps) {
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const categories = getEnabledCategories();
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved) setDarkMode(JSON.parse(saved));
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
// Compact header on scroll
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Close mobile menu on outside click
useEffect(() => {
if (!mobileMenuOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMobileMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [mobileMenuOpen]);
const handleMobileLinkClick = () => setMobileMenuOpen(false);
const handleDarkModeToggle = () => {
setDarkMode(!darkMode);
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
};
return (
<header
className={`sticky top-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-white/95 dark:bg-[#0f172a]/90 backdrop-blur-xl shadow-lg shadow-black/[0.06] dark:shadow-black/30'
: 'bg-white dark:bg-[#0f172a]'
} border-b border-gray-200 dark:border-white/[0.08]`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className={`flex items-center justify-between transition-all duration-300 ${scrolled ? 'h-12 sm:h-14' : 'h-14 sm:h-16'}`}>
{/* Logo */}
<Link
href="/"
className="flex-shrink-0 flex items-center gap-2.5 group"
aria-label="Filamenteka - Pocetna"
>
<img
src="/logo.png"
alt="Filamenteka"
loading="lazy"
decoding="async"
className={`transition-all duration-300 ${scrolled ? 'h-7 sm:h-8' : 'h-8 sm:h-10'} w-auto group-hover:brightness-110 group-hover:scale-105`}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<span className="hidden sm:inline text-xl font-extrabold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
<span className="text-gray-900 dark:text-white">Filament</span>
<span className="gradient-text">eka</span>
</span>
</Link>
{/* Desktop: Category Nav */}
<div className="hidden md:block flex-1 mx-6 overflow-x-auto scrollbar-hide">
<CategoryNav currentSlug={currentCategory} />
</div>
{/* Right side actions */}
<div className="flex-shrink-0 flex items-center gap-2 sm:gap-2.5">
{/* KP link — desktop */}
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Header')}
className="hidden sm:inline-flex items-center gap-1.5 px-4 py-1.5
bg-gradient-to-r from-emerald-500 to-teal-500
hover:from-emerald-400 hover:to-teal-400
text-white text-sm font-bold rounded-full
shadow-md shadow-emerald-500/30 hover:shadow-lg hover:shadow-teal-500/40
transition-all duration-200 active:scale-95 hover:scale-[1.03]"
title="KupujemProdajem"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
KP
</a>
{/* Dark mode toggle */}
{mounted ? (
<button
onClick={handleDarkModeToggle}
className="relative p-2.5 rounded-full text-gray-500 dark:text-gray-300
hover:text-amber-500 dark:hover:text-amber-400
hover:bg-amber-50 dark:hover:bg-amber-500/10
transition-all duration-200 hover:scale-110"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
aria-label={darkMode ? 'Prebaci na svetlu temu' : 'Prebaci na tamnu temu'}
>
{darkMode ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)}
</button>
) : (
<div className="w-10 h-10" aria-hidden="true" />
)}
{/* Hamburger — mobile */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-xl text-gray-700 dark:text-gray-200
hover:bg-gray-100 dark:hover:bg-white/10
transition-all duration-200"
aria-label={mobileMenuOpen ? 'Zatvori meni' : 'Otvori meni'}
aria-expanded={mobileMenuOpen}
>
<div className="w-6 h-5 flex flex-col justify-between relative">
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? 'rotate-45 translate-y-[9px]' : ''}`} />
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-200 ${mobileMenuOpen ? 'opacity-0 scale-0' : ''}`} />
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? '-rotate-45 -translate-y-[9px]' : ''}`} />
</div>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div
ref={menuRef}
className={`md:hidden overflow-hidden transition-all duration-300 ease-in-out ${
mobileMenuOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="border-t border-gray-200/50 dark:border-white/[0.08]">
<nav className="max-w-7xl mx-auto px-4 py-3 space-y-1" aria-label="Kategorije proizvoda">
{categories.map((cat) => {
const isActive = currentCategory === cat.slug;
return (
<Link
key={cat.slug}
href={`/${cat.slug}`}
onClick={handleMobileLinkClick}
className={`flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
isActive
? 'text-white shadow-lg scale-[1.01]'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.06] hover:scale-[1.01]'
}`}
style={isActive ? {
backgroundColor: cat.colorHex,
boxShadow: `0 6px 20px ${cat.colorHex}50`,
} : undefined}
>
{/* Colored dot indicator */}
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-white/60' : ''}`}
style={!isActive ? { backgroundColor: cat.colorHex } : undefined}
/>
<CategoryIcon slug={cat.slug} className="w-5 h-5" />
<span className="font-bold">{cat.label}</span>
{isActive && (
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</Link>
);
})}
{/* KP link in mobile menu */}
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent('External Link', 'Kupujem Prodajem', 'Mobile Menu');
handleMobileLinkClick();
}}
className="flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-bold
text-emerald-600 dark:text-emerald-400
hover:bg-emerald-50 dark:hover:bg-emerald-900/20
transition-all duration-200 hover:scale-[1.01]"
>
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-emerald-500" />
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
<span>KupujemProdajem</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-50" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'sale';
size?: 'sm' | 'md';
}
const variantStyles: Record<NonNullable<BadgeProps['variant']>, string> = {
default:
'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-300',
success:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
warning:
'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
danger:
'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-300',
info:
'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',
sale:
'bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-500/20',
};
const sizeStyles: Record<NonNullable<BadgeProps['size']>, string> = {
sm: 'px-2 py-0.5 text-[11px]',
md: 'px-2.5 py-1 text-xs',
};
export function Badge({
children,
variant = 'default',
size = 'sm',
}: BadgeProps) {
return (
<span
className={`
inline-flex items-center rounded-full font-semibold whitespace-nowrap tracking-wide
${variantStyles[variant]}
${sizeStyles[size]}
`}
>
{variant === 'sale' && (
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
)}
{children}
</span>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const variantStyles: Record<NonNullable<ButtonProps['variant']>, string> = {
primary:
'bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 focus:ring-blue-500 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800',
secondary:
'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
danger:
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800',
ghost:
'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400 dark:text-gray-300 dark:hover:bg-gray-800',
};
const sizeStyles: Record<NonNullable<ButtonProps['size']>, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export function Button({
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
className = '',
children,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || isLoading}
className={`
inline-flex items-center justify-center gap-2 rounded-md font-medium
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{isLoading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
onClick?: () => void;
}
export function Card({
children,
className = '',
hover = false,
onClick,
}: CardProps) {
return (
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
className={`
bg-white dark:bg-gray-800 rounded-lg shadow-md
border border-gray-200 dark:border-gray-700
${hover ? 'transition-all duration-200 hover:shadow-lg hover:scale-[1.02] cursor-pointer' : ''}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
interface CategoryIconProps {
slug: string;
className?: string;
}
export function CategoryIcon({ slug, className = 'w-6 h-6' }: CategoryIconProps) {
const svgProps = {
className,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
switch (slug) {
case 'filamenti':
// Spool / reel
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3" />
<line x1="12" y1="3" x2="12" y2="9" />
<line x1="12" y1="15" x2="12" y2="21" />
</svg>
);
case 'stampaci':
// 3D cube
return (
<svg {...svgProps}>
<path d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
);
case 'ploce':
// Build plate with alignment marks
return (
<svg {...svgProps}>
<rect x="3" y="8" width="18" height="8" rx="1.5" />
<path d="M7 8V5.5M12 8V4.5M17 8V5.5" />
</svg>
);
case 'mlaznice':
// Nozzle / hotend
return (
<svg {...svgProps}>
<path d="M8 3h8v4l-2 7h-4L8 7V3Z" />
<path d="M10 14v3M14 14v3" />
<circle cx="12" cy="20" r="1.5" />
</svg>
);
case 'delovi':
// Gear / settings
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3m0 14v3M2 12h3m14 0h3M4.93 4.93l2.12 2.12m9.9 9.9 2.12 2.12M4.93 19.07l2.12-2.12m9.9-9.9 2.12-2.12" />
</svg>
);
case 'oprema':
// Wrench
return (
<svg {...svgProps}>
<path d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z" />
</svg>
);
default:
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="9" />
</svg>
);
}
}

View File

@@ -0,0 +1,50 @@
'use client';
import React from 'react';
interface EmptyStateProps {
title: string;
description?: string;
icon?: React.ReactNode;
}
const defaultIcon = (
<svg
className="w-14 h-14 text-gray-300 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
);
export function EmptyState({
title,
description,
icon,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="mb-5 p-4 rounded-2xl bg-gray-100/80 dark:bg-white/[0.04]">
{icon || defaultIcon}
</div>
<h3
className="text-lg font-bold mb-2"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
{title}
</h3>
{description && (
<p className="text-sm max-w-sm" style={{ color: 'var(--text-muted)' }}>
{description}
</p>
)}
</div>
);
}

104
src/components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,104 @@
'use client';
import React, { useEffect, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}
const sizeStyles: Record<NonNullable<ModalProps['size']>, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
};
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div
className={`
relative bg-white dark:bg-gray-800 rounded-lg shadow-xl
w-full ${sizeStyles[size]} p-6
border border-gray-200 dark:border-gray-700
`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
{title && (
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{title}
</h2>
)}
<button
onClick={onClose}
className="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-md p-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Zatvori"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
{children}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import React from 'react';
import { Badge } from './Badge';
interface PriceDisplayProps {
price: number;
saleActive?: boolean;
salePercentage?: number;
className?: string;
}
function formatPrice(price: number): string {
return price.toLocaleString('sr-RS');
}
function calculateSalePrice(price: number, percentage: number): number {
return Math.round(price * (1 - percentage / 100));
}
export function PriceDisplay({
price,
saleActive = false,
salePercentage = 0,
className = '',
}: PriceDisplayProps) {
const salePrice = saleActive && salePercentage > 0
? calculateSalePrice(price, salePercentage)
: price;
if (saleActive && salePercentage > 0) {
return (
<div className={`inline-flex items-center gap-2 ${className}`}>
<span className="line-through text-sm" style={{ color: 'var(--text-muted)' }}>
{formatPrice(price)} RSD
</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">
{formatPrice(salePrice)} RSD
</span>
<Badge variant="sale" size="sm">
-{salePercentage}%
</Badge>
</div>
);
}
return (
<span className={`font-semibold ${className}`} style={{ color: 'var(--text-primary)' }}>
{formatPrice(price)} <span className="text-sm font-normal" style={{ color: 'var(--text-muted)' }}>RSD</span>
</span>
);
}

148
src/config/categories.ts Normal file
View File

@@ -0,0 +1,148 @@
import { ProductCategory } from '@/src/types/product';
export interface CategoryFilter {
key: string;
label: string;
type: 'select' | 'range' | 'checkbox';
options?: string[];
}
export interface CategoryConfig {
slug: string;
label: string;
labelShort: string;
labelEn: string;
apiCategory?: ProductCategory;
color: string;
colorHex: string;
icon: string;
description: string;
metaTitle: string;
metaDescription: string;
filters: CategoryFilter[];
enabled: boolean;
isFilament?: boolean;
}
export const CATEGORIES: CategoryConfig[] = [
{
slug: 'filamenti',
label: 'Filamenti',
labelShort: 'Filamenti',
labelEn: 'Filaments',
color: 'blue',
colorHex: '#3b82f6',
icon: '🧵',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU i mnogi drugi materijali.',
metaTitle: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
metaDescription: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
filters: [
{ key: 'material', label: 'Materijal', type: 'select', options: ['ABS', 'ASA', 'PA6', 'PAHT', 'PC', 'PET', 'PETG', 'PLA', 'PPA', 'PPS', 'TPU'] },
{ key: 'finish', label: 'Finish', type: 'select' },
{ key: 'color', label: 'Boja', type: 'select' },
],
enabled: true,
isFilament: true,
},
{
slug: 'stampaci',
label: '3D Stampaci',
labelShort: 'Stampaci',
labelEn: '3D Printers',
apiCategory: 'printer',
color: 'red',
colorHex: '#ef4444',
icon: '🖨️',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
metaTitle: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
metaDescription: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
],
enabled: true,
},
{
slug: 'ploce',
label: 'Build Plate (Podloge)',
labelShort: 'Podloge',
labelEn: 'Build Plates',
apiCategory: 'build_plate',
color: 'green',
colorHex: '#22c55e',
icon: '📐',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
metaTitle: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
metaDescription: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'mlaznice',
label: 'Mlaznice i Hotend (Nozzles)',
labelShort: 'Mlaznice',
labelEn: 'Nozzles & Hotend',
apiCategory: 'nozzle',
color: 'amber',
colorHex: '#f59e0b',
icon: '🔩',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
metaTitle: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
metaDescription: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'delovi',
label: 'Rezervni Delovi (Spare Parts)',
labelShort: 'Delovi',
labelEn: 'Spare Parts',
apiCategory: 'spare_part',
color: 'purple',
colorHex: '#a855f7',
icon: '🔧',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
metaTitle: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
metaDescription: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'oprema',
label: 'Oprema i Dodaci (Accessories)',
labelShort: 'Oprema',
labelEn: 'Accessories',
apiCategory: 'accessory',
color: 'cyan',
colorHex: '#06b6d4',
icon: '🛠️',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
metaTitle: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
metaDescription: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
],
enabled: true,
},
];
export const getCategoryBySlug = (slug: string): CategoryConfig | undefined =>
CATEGORIES.find(c => c.slug === slug);
export const getEnabledCategories = (): CategoryConfig[] =>
CATEGORIES.filter(c => c.enabled);
export const CONDITION_LABELS: Record<string, string> = {
new: 'Novo',
used_like_new: 'Kao novo',
used_good: 'Dobro stanje',
used_fair: 'Korisceno',
};

View File

@@ -0,0 +1,59 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface DarkModeContextType {
darkMode: boolean;
setDarkMode: (value: boolean) => void;
toggleDarkMode: () => void;
mounted: boolean;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export function DarkModeProvider({
children,
defaultDark = false,
}: {
children: ReactNode;
defaultDark?: boolean;
}) {
const [darkMode, setDarkMode] = useState(defaultDark);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
setDarkMode(JSON.parse(saved));
} else if (defaultDark) {
setDarkMode(true);
}
}, [defaultDark]);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
const toggleDarkMode = () => setDarkMode(prev => !prev);
return (
<DarkModeContext.Provider value={{ darkMode, setDarkMode, toggleDarkMode, mounted }}>
{children}
</DarkModeContext.Provider>
);
}
export function useDarkMode(): DarkModeContextType {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
}

36
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,36 @@
'use client';
import { useState, useEffect } from 'react';
interface UseAuthResult {
isAuthenticated: boolean;
isLoading: boolean;
logout: () => void;
}
export function useAuth(): UseAuthResult {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (token && expiry && Date.now() <= parseInt(expiry)) {
setIsAuthenticated(true);
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
window.location.href = '/upadaj';
}
setIsLoading(false);
}, []);
const logout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
window.location.href = '/upadaj';
};
return { isAuthenticated, isLoading, logout };
}

47
src/hooks/useProducts.ts Normal file
View File

@@ -0,0 +1,47 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Product, ProductCategory } from '@/src/types/product';
import { productService } from '@/src/services/api';
interface UseProductsOptions {
category?: ProductCategory;
condition?: string;
printer_model?: string;
in_stock?: boolean;
search?: string;
}
interface UseProductsResult {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useProducts(options: UseProductsOptions = {}): UseProductsResult {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchProducts = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await productService.getAll(options);
setProducts(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Greska pri ucitavanju proizvoda';
setError(message);
console.error('Error fetching products:', err);
} finally {
setLoading(false);
}
}, [options.category, options.condition, options.printer_model, options.in_stock, options.search]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
return { products, loading, error, refetch: fetchProducts };
}

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { Filament } from '@/src/types/filament';
import { Product, ProductCategory, InventoryStats, SalesStats } from '@/src/types/product';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
@@ -132,4 +133,74 @@ export const colorRequestService = {
},
};
export const productService = {
getAll: async (params: {
category?: ProductCategory;
condition?: string;
printer_model?: string;
in_stock?: boolean;
search?: string;
} = {}) => {
const cacheBuster = Date.now();
const queryParams = new URLSearchParams({ _t: String(cacheBuster) });
if (params.category) queryParams.set('category', params.category);
if (params.condition) queryParams.set('condition', params.condition);
if (params.printer_model) queryParams.set('printer_model', params.printer_model);
if (params.in_stock !== undefined) queryParams.set('in_stock', String(params.in_stock));
if (params.search) queryParams.set('search', params.search);
const response = await api.get(`/products?${queryParams.toString()}`);
return response.data;
},
getById: async (id: string) => {
const response = await api.get(`/products/${id}`);
return response.data;
},
create: async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
const response = await api.post('/products', product);
return response.data;
},
update: async (id: string, product: Partial<Product> & { printer_model_ids?: string[] }) => {
const response = await api.put(`/products/${id}`, product);
return response.data;
},
delete: async (id: string) => {
const response = await api.delete(`/products/${id}`);
return response.data;
},
updateBulkSale: async (data: {
productIds?: string[];
salePercentage: number;
saleStartDate?: string;
saleEndDate?: string;
enableSale: boolean;
}) => {
const response = await api.post('/products/sale/bulk', data);
return response.data;
},
};
export const printerModelService = {
getAll: async () => {
const response = await api.get('/printer-models');
return response.data;
},
};
export const analyticsService = {
getInventory: async (): Promise<InventoryStats> => {
const response = await api.get('/analytics/inventory');
return response.data;
},
getSales: async (): Promise<SalesStats> => {
const response = await api.get('/analytics/sales');
return response.data;
},
};
export default api;

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Nunito+Sans:ital,opsz,wght@0,6..12,300..1000;1,6..12,300..1000&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -10,43 +12,244 @@
z-index: 9999;
}
/* Prevent white flash on admin pages */
@layer base {
html {
background-color: rgb(249 250 251);
:root {
--font-display: 'Sora', system-ui, sans-serif;
--font-body: 'Nunito Sans', system-ui, sans-serif;
/* Category palette */
--color-filamenti: #3b82f6;
--color-stampaci: #ef4444;
--color-ploce: #22c55e;
--color-mlaznice: #f59e0b;
--color-delovi: #a855f7;
--color-oprema: #06b6d4;
/* Surface tokens — light (vibrant, clean) */
--surface-primary: #f8fafc;
--surface-secondary: #f1f5f9;
--surface-elevated: #ffffff;
--text-primary: #0f172a;
--text-secondary: #334155;
--text-muted: #64748b;
--border-color: #e2e8f0;
--border-subtle: #e2e8f0;
}
html.dark {
background-color: rgb(17 24 39);
--surface-primary: #0f172a;
--surface-secondary: #1e293b;
--surface-elevated: #1e293b;
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border-color: rgba(255, 255, 255, 0.1);
--border-subtle: rgba(255, 255, 255, 0.06);
}
html {
background-color: var(--surface-primary);
overflow-x: hidden;
scroll-behavior: smooth;
}
html.dark {
background-color: var(--surface-primary);
}
body {
@apply bg-gray-50 dark:bg-gray-900 transition-none;
font-family: var(--font-body);
font-weight: 400;
background-color: var(--surface-primary);
color: var(--text-primary);
transition: none;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
letter-spacing: -0.03em;
font-weight: 700;
}
/* Disable transitions on page load to prevent flash */
.no-transitions * {
transition: none !important;
}
}
/* Shimmer animation for sale banner */
/* ─── Noise texture overlay ─── */
.noise-overlay::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 1;
}
.dark .noise-overlay::after {
opacity: 0.04;
}
/* ─── Gradient text utility ─── */
.gradient-text {
background: linear-gradient(135deg, #2563eb, #7c3aed, #c026d3);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
html.dark .gradient-text {
background: linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ─── Hero rainbow gradient ─── */
.hero-gradient-text {
background: linear-gradient(90deg, #3b82f6, #a855f7, #ef4444, #f59e0b, #22c55e, #06b6d4);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 6s ease-in-out infinite;
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% center; }
50% { background-position: 100% center; }
}
/* ─── Glass card ─── */
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dark .glass-card {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
}
/* ─── Category card hover glow ─── */
.category-card {
position: relative;
overflow: hidden;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease;
}
.category-card:hover {
transform: translateY(-6px) scale(1.02);
}
/* Light mode: softer shadow for elevation on light backgrounds */
:root .category-card {
box-shadow: 0 2px 12px -2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
}
:root .category-card:hover {
box-shadow: 0 12px 40px -8px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.06);
}
html.dark .category-card {
box-shadow: none;
}
html.dark .category-card:hover {
box-shadow: none;
}
/* ─── Shimmer animation ─── */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.animate-shimmer {
animation: shimmer 3s ease-in-out infinite;
}
/* Safari form styling fixes */
/* ─── Float animation ─── */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-16px); }
}
.animate-float {
animation: float 5s ease-in-out infinite;
}
.animate-float-slow {
animation: float 7s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 6s ease-in-out 1.5s infinite;
}
/* ─── Pulse glow ─── */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); }
50% { box-shadow: 0 0 40px rgba(139, 92, 246, 0.6); }
}
/* ─── Gradient orbit background ─── */
@keyframes orbit {
0% { transform: rotate(0deg) translateX(100px) rotate(0deg); }
100% { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
}
.animate-orbit {
animation: orbit 20s linear infinite;
}
.animate-orbit-reverse {
animation: orbit 25s linear infinite reverse;
}
/* ─── Staggered reveal ─── */
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal-up {
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.reveal-delay-1 { animation-delay: 0.08s; }
.reveal-delay-2 { animation-delay: 0.16s; }
.reveal-delay-3 { animation-delay: 0.24s; }
.reveal-delay-4 { animation-delay: 0.32s; }
.reveal-delay-5 { animation-delay: 0.40s; }
.reveal-delay-6 { animation-delay: 0.48s; }
/* ─── Scrollbar hide ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* ─── Safari form styling fixes ─── */
@layer base {
/* Remove Safari's default styling for form inputs */
input[type="text"],
input[type="email"],
input[type="url"],
@@ -61,7 +264,6 @@
appearance: none;
}
/* Fix Safari select arrow */
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
@@ -70,19 +272,14 @@
padding-right: 2.5rem;
}
/* Dark mode select arrow */
.dark select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
}
/* Ensure consistent border radius on iOS */
input,
select,
textarea {
border-radius: 0.375rem;
input, select, textarea {
border-radius: 0.5rem;
}
/* Remove iOS zoom on focus */
@media screen and (max-width: 768px) {
input[type="text"],
input[type="email"],

73
src/types/product.ts Normal file
View File

@@ -0,0 +1,73 @@
export type ProductCategory = 'printer' | 'build_plate' | 'nozzle' | 'spare_part' | 'accessory';
export type ProductCondition = 'new' | 'used_like_new' | 'used_good' | 'used_fair';
export interface Product {
id: string;
category: ProductCategory;
name: string;
slug: string;
description?: string;
price: number;
condition: ProductCondition;
stock: number;
attributes: Record<string, unknown>;
sale_active?: boolean;
sale_percentage?: number;
sale_start_date?: string;
sale_end_date?: string;
image_url?: string;
is_active?: boolean;
compatible_printers?: string[];
created_at?: string;
updated_at?: string;
}
export interface PrinterModel {
id: string;
name: string;
series: string;
}
export interface InventoryStats {
filaments: {
total_skus: number;
total_units: number;
total_refills: number;
total_spools: number;
out_of_stock: number;
inventory_value: number;
};
products: {
total_skus: number;
total_units: number;
out_of_stock: number;
inventory_value: number;
by_category: Record<string, number>;
};
combined: {
total_skus: number;
total_units: number;
out_of_stock: number;
};
}
export interface SalesStats {
filament_sales: Array<{
id: string;
name: string;
sale_percentage: number;
sale_end_date?: string;
original_price: number;
sale_price: number;
}>;
product_sales: Array<{
id: string;
name: string;
category: ProductCategory;
sale_percentage: number;
sale_end_date?: string;
original_price: number;
sale_price: number;
}>;
total_active_sales: number;
}

File diff suppressed because one or more lines are too long