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:
18
app/delovi/layout.tsx
Normal file
18
app/delovi/layout.tsx
Normal 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
43
app/delovi/page.tsx
Normal 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
18
app/filamenti/layout.tsx
Normal 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
43
app/filamenti/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,12 +75,16 @@ export default function RootLayout({
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
<BackToTop />
|
||||
{process.env.NEXT_PUBLIC_MATOMO_URL && process.env.NEXT_PUBLIC_MATOMO_SITE_ID && (
|
||||
<MatomoAnalytics
|
||||
<MatomoAnalytics
|
||||
matomoUrl={process.env.NEXT_PUBLIC_MATOMO_URL}
|
||||
siteId={process.env.NEXT_PUBLIC_MATOMO_SITE_ID}
|
||||
/>
|
||||
@@ -56,4 +92,4 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
18
app/mlaznice/layout.tsx
Normal file
18
app/mlaznice/layout.tsx
Normal 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
43
app/mlaznice/page.tsx
Normal 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
18
app/oprema/layout.tsx
Normal 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
43
app/oprema/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
587
app/page.tsx
587
app/page.tsx
@@ -1,277 +1,384 @@
|
||||
'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');
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
const data = await filamentService.getAll();
|
||||
setFilaments(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch filaments for sale check:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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' });
|
||||
};
|
||||
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 ?? null;
|
||||
})();
|
||||
|
||||
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 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>
|
||||
</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"
|
||||
|
||||
{/* 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-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';
|
||||
}}
|
||||
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'; }}
|
||||
/>
|
||||
</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) => {
|
||||
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;
|
||||
})()}
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<ColorRequestModal
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
app/ploce/layout.tsx
Normal file
18
app/ploce/layout.tsx
Normal 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
43
app/ploce/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
app/politika-privatnosti/page.tsx
Normal file
205
app/politika-privatnosti/page.tsx
Normal 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 "Do Not
|
||||
Track" 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 ("pravo na zaborav")</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
18
app/stampaci/layout.tsx
Normal 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
43
app/stampaci/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
13
app/upadaj/dashboard/[category]/layout.tsx
Normal file
13
app/upadaj/dashboard/[category]/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
577
app/upadaj/dashboard/[category]/page.tsx
Normal file
577
app/upadaj/dashboard/[category]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
460
app/upadaj/dashboard/analitika/page.tsx
Normal file
460
app/upadaj/dashboard/analitika/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
448
app/upadaj/dashboard/boje/page.tsx
Normal file
448
app/upadaj/dashboard/boje/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
990
app/upadaj/dashboard/filamenti/page.tsx
Normal file
990
app/upadaj/dashboard/filamenti/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/upadaj/dashboard/layout.tsx
Normal file
8
app/upadaj/dashboard/layout.tsx
Normal 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>;
|
||||
}
|
||||
249
app/upadaj/dashboard/page.tsx
Normal file
249
app/upadaj/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
360
app/upadaj/dashboard/prodaja/page.tsx
Normal file
360
app/upadaj/dashboard/prodaja/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal file
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,28 +44,31 @@ 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
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
<img
|
||||
src="/logo.png"
|
||||
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>
|
||||
@@ -113,4 +117,4 @@ export default function AdminLogin() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
app/uslovi-koriscenja/page.tsx
Normal file
208
app/uslovi-koriscenja/page.tsx
Normal 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 "takve kakve jesu". 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user