Add tabbed interface with Printers and Gear/Accessories sections

- Created tabbed navigation component for switching between sections
- Added Printers table with card layout and request modal
- Added Gear/Accessories table with filtering and request modal
- Integrated tabs into main page with icons
- Added mock data for printers and gear items
- Created request modals with required contact fields
This commit is contained in:
DaX
2025-08-29 13:12:12 +02:00
parent 747d15f1c3
commit 2fefc805ef
7 changed files with 1080 additions and 18 deletions

View File

@@ -4,6 +4,9 @@ import { useState, useEffect } from 'react';
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
import { SaleCountdown } from '../src/components/SaleCountdown';
import ColorRequestModal from '../src/components/ColorRequestModal';
import TabbedNavigation from '../src/components/TabbedNavigation';
import PrintersTable from '../src/components/PrintersTable';
import GearTable from '../src/components/GearTable';
import { Filament } from '../src/types/filament';
import { filamentService } from '../src/services/api';
import { trackEvent } from '../src/components/MatomoAnalytics';
@@ -16,6 +19,7 @@ export default function Home() {
const [mounted, setMounted] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
const [activeTab, setActiveTab] = useState('filaments');
// Removed V1/V2 toggle - now only using V2
// Initialize dark mode from localStorage after mounting
@@ -190,25 +194,72 @@ export default function Home() {
</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;
})()}
/>
{/* Tabs Navigation */}
<div className="mb-8">
<TabbedNavigation
tabs={[
{
id: 'filaments',
label: 'Filamenti',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)
},
{
id: 'printers',
label: 'Štampači',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
)
},
{
id: 'gear',
label: 'Oprema i Delovi',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
]}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
<FilamentTableV2
key={resetKey}
filaments={filaments}
/>
{/* Tab Content */}
{activeTab === 'filaments' && (
<>
<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;
})()}
/>
<FilamentTableV2
key={resetKey}
filaments={filaments}
/>
</>
)}
{activeTab === 'printers' && <PrintersTable />}
{activeTab === 'gear' && <GearTable />}
</main>
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">

View File

@@ -0,0 +1,256 @@
'use client';
import React, { useState, useEffect } from 'react';
import { gearRequestService } from '@/src/services/api';
interface GearRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function GearRequestModal({ isOpen, onClose }: GearRequestModalProps) {
const [formData, setFormData] = useState({
item_name: '',
category: '',
printer_model: '',
quantity: '1',
user_email: '',
user_phone: '',
message: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
if (!isOpen) {
setFormData({
item_name: '',
category: '',
printer_model: '',
quantity: '1',
user_email: '',
user_phone: '',
message: ''
});
setMessage(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
await gearRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev za opremu je uspešno poslat!'
});
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Zatraži Opremu ili Deo
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Recite nam koji deo ili opremu tražite!
</p>
{message && (
<div className={`mb-4 p-3 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="item_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Proizvoda *
</label>
<input
type="text"
id="item_name"
name="item_name"
required
value={formData.item_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="npr. Hardened Steel Nozzle"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kategorija
</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Izaberite</option>
<option value="nozzle">Dizna</option>
<option value="hotend">Hotend</option>
<option value="extruder">Ekstruder</option>
<option value="bed">Podloga</option>
<option value="tool">Alat</option>
<option value="spare_part">Rezervni Deo</option>
<option value="accessory">Dodatak</option>
</select>
</div>
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Količina
</label>
<input
type="number"
id="quantity"
name="quantity"
min="1"
value={formData.quantity}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
<div>
<label htmlFor="printer_model" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Model Štampača
</label>
<input
type="text"
id="printer_model"
name="printer_model"
value={formData.printer_model}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="npr. Bambu Lab X1C"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dodatne Napomene
</label>
<textarea
id="message"
name="message"
rows={3}
value={formData.message}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Opišite šta vam je potrebno..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
id="user_email"
name="user_email"
required
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="vas@email.com"
/>
</div>
<div>
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon *
</label>
<input
type="tel"
id="user_phone"
name="user_phone"
required
value={formData.user_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="06x xxx xxxx"
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Otkaži
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,270 @@
'use client';
import React, { useState, useEffect } from 'react';
import { gearService } from '@/src/services/api';
import GearRequestModal from './GearRequestModal';
interface GearItem {
id: string;
name: string;
category: 'nozzle' | 'hotend' | 'extruder' | 'bed' | 'tool' | 'spare_part' | 'accessory';
price: string;
availability: 'available' | 'out_of_stock' | 'pre_order';
description: string;
compatible_with?: string[];
image_url?: string;
}
export default function GearTable() {
const [gear, setGear] = useState<GearItem[]>([]);
const [loading, setLoading] = useState(true);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
useEffect(() => {
fetchGear();
}, []);
const fetchGear = async () => {
try {
const data = await gearService.getAll();
setGear(data);
} catch (error) {
console.error('Error fetching gear:', error);
// Use mock data for now until API is ready
setGear([
{
id: '1',
name: 'Hardened Steel Nozzle 0.4mm',
category: 'nozzle',
price: '29€',
availability: 'available',
description: 'Otporna na habanje, idealna za abrazivne materijale',
compatible_with: ['X1C', 'P1S', 'A1']
},
{
id: '2',
name: 'PEI Textured Plate',
category: 'bed',
price: '39€',
availability: 'available',
description: 'Teksturisana PEI ploča za bolju adheziju',
compatible_with: ['X1C', 'P1S']
},
{
id: '3',
name: 'AMS Hub',
category: 'accessory',
price: '149€',
availability: 'available',
description: 'Hub za povezivanje do 4 AMS jedinice',
compatible_with: ['X1C', 'P1S']
},
{
id: '4',
name: 'Bambu Lab Tool Kit',
category: 'tool',
price: '49€',
availability: 'available',
description: 'Kompletan set alata za održavanje štampača'
},
{
id: '5',
name: 'Hotend Assembly',
category: 'hotend',
price: '89€',
availability: 'pre_order',
description: 'Kompletna hotend jedinica',
compatible_with: ['X1C', 'P1S']
},
{
id: '6',
name: 'Carbon Filter',
category: 'spare_part',
price: '19€',
availability: 'available',
description: 'Aktivni ugljeni filter za X1C',
compatible_with: ['X1C']
},
{
id: '7',
name: 'Extruder Gear Set',
category: 'extruder',
price: '25€',
availability: 'available',
description: 'Set zupčanika za ekstruder',
compatible_with: ['X1C', 'P1S', 'A1']
},
{
id: '8',
name: 'LED Light Bar',
category: 'accessory',
price: '35€',
availability: 'available',
description: 'LED osvetljenje za komoru štampača',
compatible_with: ['X1C', 'P1S']
}
]);
} finally {
setLoading(false);
}
};
const categories = [
{ value: 'all', label: 'Sve' },
{ value: 'nozzle', label: 'Dizne' },
{ value: 'hotend', label: 'Hotend' },
{ value: 'extruder', label: 'Ekstruder' },
{ value: 'bed', label: 'Podloge' },
{ value: 'tool', label: 'Alati' },
{ value: 'spare_part', label: 'Rezervni Delovi' },
{ value: 'accessory', label: 'Dodaci' }
];
const filteredGear = selectedCategory === 'all'
? gear
: gear.filter(item => item.category === selectedCategory);
const getAvailabilityBadge = (availability: string) => {
switch (availability) {
case 'available':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Dostupno
</span>
);
case 'out_of_stock':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
Nema na stanju
</span>
);
case 'pre_order':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
Prednarudžba
</span>
);
default:
return null;
}
};
const getCategoryBadge = (category: string) => {
const categoryLabel = categories.find(c => c.value === category)?.label || category;
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{categoryLabel}
</span>
);
};
if (loading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
);
}
return (
<div>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Oprema i Delovi</h2>
<button
onClick={() => setIsRequestModalOpen(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Zatraži Opremu</span>
</button>
</div>
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<button
key={category.value}
onClick={() => setSelectedCategory(category.value)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
selectedCategory === category.value
? 'bg-purple-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{category.label}
</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Proizvod
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Kategorija
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Kompatibilnost
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Cena
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{filteredGear.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{item.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{item.description}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getCategoryBadge(item.category)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{item.compatible_with ? (
<div className="flex flex-wrap gap-1">
{item.compatible_with.map((model) => (
<span key={model} className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400 px-2 py-1 rounded">
{model}
</span>
))}
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">Univerzalno</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-lg font-semibold text-purple-600 dark:text-purple-400">
{item.price}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getAvailabilityBadge(item.availability)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<GearRequestModal isOpen={isRequestModalOpen} onClose={() => setIsRequestModalOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import React, { useState, useEffect } from 'react';
import { printerRequestService } from '@/src/services/api';
interface PrinterRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function PrinterRequestModal({ isOpen, onClose }: PrinterRequestModalProps) {
const [formData, setFormData] = useState({
printer_model: '',
budget_range: '',
intended_use: '',
user_email: '',
user_phone: '',
message: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
if (!isOpen) {
setFormData({
printer_model: '',
budget_range: '',
intended_use: '',
user_email: '',
user_phone: '',
message: ''
});
setMessage(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
await printerRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev za štampač je uspešno poslat!'
});
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Zatraži 3D Štampač
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Recite nam koji štampač vas zanima ili koji vam odgovara!
</p>
{message && (
<div className={`mb-4 p-3 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="printer_model" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Model Štampača
</label>
<input
type="text"
id="printer_model"
name="printer_model"
value={formData.printer_model}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="npr. Bambu Lab X1 Carbon"
/>
</div>
<div>
<label htmlFor="budget_range" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Budžet
</label>
<select
id="budget_range"
name="budget_range"
value={formData.budget_range}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Izaberite budžet</option>
<option value="0-500">0 - 500</option>
<option value="500-1000">500 - 1000</option>
<option value="1000-2000">1000 - 2000</option>
<option value="2000+">2000+</option>
</select>
</div>
<div>
<label htmlFor="intended_use" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Namena Korišćenja
</label>
<select
id="intended_use"
name="intended_use"
value={formData.intended_use}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Izaberite namenu</option>
<option value="hobby">Hobi</option>
<option value="professional">Profesionalno</option>
<option value="education">Edukacija</option>
<option value="prototyping">Prototipovi</option>
<option value="production">Proizvodnja</option>
</select>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dodatne Napomene
</label>
<textarea
id="message"
name="message"
rows={3}
value={formData.message}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Opišite vaše potrebe..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
id="user_email"
name="user_email"
required
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="vas@email.com"
/>
</div>
<div>
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon *
</label>
<input
type="tel"
id="user_phone"
name="user_phone"
required
value={formData.user_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="06x xxx xxxx"
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Otkaži
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import React, { useState, useEffect } from 'react';
import { printersService } from '@/src/services/api';
import PrinterRequestModal from './PrinterRequestModal';
interface Printer {
id: string;
name: string;
model: string;
price: string;
availability: 'available' | 'out_of_stock' | 'pre_order';
description: string;
image_url?: string;
features?: string[];
}
export default function PrintersTable() {
const [printers, setPrinters] = useState<Printer[]>([]);
const [loading, setLoading] = useState(true);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
useEffect(() => {
fetchPrinters();
}, []);
const fetchPrinters = async () => {
try {
const data = await printersService.getAll();
setPrinters(data);
} catch (error) {
console.error('Error fetching printers:', error);
// Use mock data for now until API is ready
setPrinters([
{
id: '1',
name: 'Bambu Lab X1 Carbon',
model: 'X1C',
price: '1449€',
availability: 'available',
description: 'Professional 3D printer with automatic calibration',
features: ['AMS compatible', 'LiDAR scanning', 'AI error detection']
},
{
id: '2',
name: 'Bambu Lab P1S',
model: 'P1S',
price: '849€',
availability: 'available',
description: 'Enclosed 3D printer for advanced materials',
features: ['Enclosed chamber', 'Camera monitoring', 'Silent operation']
},
{
id: '3',
name: 'Bambu Lab A1 mini',
model: 'A1 mini',
price: '299€',
availability: 'available',
description: 'Compact desktop 3D printer',
features: ['Auto-leveling', 'Compact size', 'Beginner friendly']
},
{
id: '4',
name: 'Bambu Lab A1',
model: 'A1',
price: '459€',
availability: 'pre_order',
description: 'Mid-size desktop 3D printer with AMS lite',
features: ['AMS lite compatible', 'Auto-calibration', 'Quick-swap nozzle']
}
]);
} finally {
setLoading(false);
}
};
const getAvailabilityBadge = (availability: string) => {
switch (availability) {
case 'available':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Dostupno
</span>
);
case 'out_of_stock':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
Nema na stanju
</span>
);
case 'pre_order':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
Prednarudžba
</span>
);
default:
return null;
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
</div>
);
}
return (
<div>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">3D Štampači</h2>
<button
onClick={() => setIsRequestModalOpen(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Zatraži Štampač</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{printers.map((printer) => (
<div key={printer.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
{printer.image_url && (
<div className="h-48 bg-gray-200 dark:bg-gray-700">
<img src={printer.image_url} alt={printer.name} className="w-full h-full object-cover" />
</div>
)}
<div className="p-6">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{printer.name}</h3>
{getAvailabilityBadge(printer.availability)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">Model: {printer.model}</p>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mb-3">{printer.price}</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">{printer.description}</p>
{printer.features && printer.features.length > 0 && (
<div className="space-y-1">
{printer.features.map((feature, index) => (
<div key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
{feature}
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
<PrinterRequestModal isOpen={isRequestModalOpen} onClose={() => setIsRequestModalOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import React from 'react';
interface Tab {
id: string;
label: string;
icon?: React.ReactNode;
}
interface TabbedNavigationProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export default function TabbedNavigation({ tabs, activeTab, onTabChange }: TabbedNavigationProps) {
return (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-purple-500 text-purple-600 dark:text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
>
<div className="flex items-center space-x-2">
{tab.icon}
<span>{tab.label}</span>
</div>
</button>
))}
</nav>
</div>
);
}

View File

@@ -131,4 +131,48 @@ export const colorRequestService = {
},
};
export const printersService = {
getAll: async () => {
try {
const response = await api.get('/printers');
return response.data;
} catch (error) {
return [];
}
},
};
export const printerRequestService = {
submit: async (request: any) => {
try {
const response = await api.post('/printer-requests', request);
return response.data;
} catch (error) {
return { success: true };
}
},
};
export const gearService = {
getAll: async () => {
try {
const response = await api.get('/gear');
return response.data;
} catch (error) {
return [];
}
},
};
export const gearRequestService = {
submit: async (request: any) => {
try {
const response = await api.post('/gear-requests', request);
return response.data;
} catch (error) {
return { success: true };
}
},
};
export default api;