3 Commits

Author SHA1 Message Date
DaX
f1f3a65dfd Update documentation and clean up component code 2025-09-24 18:15:19 +02:00
DaX
59304a88f4 Center tabs and move color request to dedicated tab
- Centered tab navigation for better visual balance
- Created dedicated Color Request tab with informative layout
- Removed standalone color request button from main actions
- Added statistics and info to color request section
- Shortened 'Oprema i Delovi' to just 'Oprema' for cleaner tabs
2025-08-29 13:22:34 +02:00
DaX
2fefc805ef 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
2025-08-29 13:12:12 +02:00
17 changed files with 1326 additions and 443 deletions

229
CLAUDE.md
View File

@@ -1,87 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance when working with code in this repository.
## Project Overview
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export)
- **Backend**: Node.js API server with PostgreSQL database
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database)
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments with:
- **Frontend**: Next.js 15 app with React 19, TypeScript 5.2.2, and Tailwind CSS (static export)
- **Backend**: Node.js Express API server with PostgreSQL database
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database), managed via Terraform
## Critical Rules
- NEVER mention ANY author in commits. No author tags or attribution
- NEVER mention AI/assistant names anywhere
- NEVER mention AI/assistant names anywhere
- Keep commit messages clean and professional with NO attribution
- Build for AMD64 Linux when deploying (development is on ARM macOS)
- Always run security checks before commits
- NEVER use mock data - all tests must use real APIs/data
## Common Commands
```bash
# Development
npm run dev # Start Next.js development server (port 3000)
npm run build # Build static export to /out directory
npm run lint # Run ESLint
npm test # Run Jest tests
npm run test:watch # Run Jest in watch mode
npm run build # Build static export to /out directory
npm run lint # Run ESLint
npm test # Run Jest tests (includes no-mock-data validation)
# Security & Quality
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
./scripts/pre-commit.sh # Runs security, build, and test checks (use before commits)
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
./scripts/pre-commit.sh # Full pre-commit validation (security, build, tests)
# Database Migrations
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
# API Server Development
cd api && npm start # Start API server locally (port 3001)
cd api && npm run dev # Start API server with nodemon
# Deployment
./scripts/deploy-api.sh # Deploy API to EC2 (builds AMD64 Docker image)
```
## Architecture
### Frontend Structure (Next.js App Router)
- `/app` - Next.js 13+ app directory structure
- `/page.tsx` - Public filament inventory table
- `/upadaj` - Admin panel (password protected)
- `/page.tsx` - Admin login
- `/dashboard/page.tsx` - Filament CRUD operations
- `/colors/page.tsx` - Color management
- `/requests/page.tsx` - Customer color requests
- `/src` - Source files
- `/components` - React components
- `/services/api.ts` - Axios instance with auth interceptors
- `/data` - Color definitions (bambuLabColors.ts, bambuLabColorsComplete.ts)
- `/types` - TypeScript type definitions
```
/app
├── page.tsx # Public filament inventory table
├── layout.tsx # Root layout with providers
└── upadaj/ # Admin panel (JWT protected)
├── page.tsx # Admin login
├── dashboard/page.tsx # Filament CRUD operations
├── colors/page.tsx # Color management
└── requests/page.tsx # Color request management
```
### API Structure
- `/api` - Node.js Express server (runs on EC2, port 80)
- `/server.js` - Main Express server with all routes inline
- Database: PostgreSQL on AWS RDS
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
```
/api
├── server.js # Express server (port 3001)
├── routes/
│ ├── filaments.js # Filament CRUD + bulk operations
│ ├── colors.js # Color management
│ ├── colorRequests.js # Color request system
│ └── auth.js # JWT authentication
└── middleware/
└── auth.js # JWT verification middleware
```
### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering
- `SaleManager` - Bulk sale management interface
- `FilamentTableV2` - Main inventory display with sorting/filtering/searching
- `SaleManager` - Bulk sale management with countdown timers
- `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system
- `ColorRequestForm` - Customer color request form
- `ColorRequestModal` - Modal for color requests
- `EnhancedFilters` - Multi-criteria filtering system
- `ColorRequestModal` - Public color request form
### Data Models
#### Filament Schema (PostgreSQL)
```sql
filaments: {
id: UUID,
id: UUID PRIMARY KEY,
tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk)
boja: VARCHAR(100), # Color name
boja: VARCHAR(100), # Color name (links to colors.name)
boja_hex: VARCHAR(7), # Color hex code
refill: INTEGER, # Refill spool count
spulna: INTEGER, # Regular spool count
kolicina: INTEGER, # Total quantity (refill + spulna)
cena: VARCHAR(50), # Price
cena: VARCHAR(50), # Price string
sale_active: BOOLEAN, # Sale status
sale_percentage: INTEGER,# Sale discount
sale_percentage: INTEGER,# Sale discount percentage
sale_end_date: TIMESTAMP # Sale expiry
}
```
@@ -89,23 +101,22 @@ filaments: {
#### Color Schema
```sql
colors: {
id: UUID,
name: VARCHAR(100), # Color name (must match filament.boja)
hex: VARCHAR(7), # Hex color code
cena_refill: INTEGER, # Refill price (default: 3499)
cena_spulna: INTEGER # Regular price (default: 3999)
id: UUID PRIMARY KEY,
name: VARCHAR(100) UNIQUE, # Color name (must match filament.boja)
hex: VARCHAR(7), # Hex color code
cena_refill: INTEGER, # Refill price (default: 3499)
cena_spulna: INTEGER # Regular price (default: 3999)
}
```
#### Color Requests Schema
```sql
color_requests: {
id: UUID,
color_name: VARCHAR(255), # Requested color name
message: TEXT, # Customer message
contact_name: VARCHAR(255), # Customer name (required)
contact_phone: VARCHAR(50), # Customer phone (required)
status: VARCHAR(20), # Status: pending, reviewed, fulfilled
id: UUID PRIMARY KEY,
color_name: VARCHAR(100),
email: VARCHAR(255),
message: TEXT,
status: VARCHAR(50), # pending/approved/rejected
created_at: TIMESTAMP
}
```
@@ -115,48 +126,49 @@ color_requests: {
### Frontend (AWS Amplify)
- Automatic deployment on push to main branch
- Build output: Static files in `/out` directory
- Build command: `npm run build`
- Config: `amplify.yml`, `next.config.js` (output: 'export')
- Security check runs during build (amplify.yml preBuild phase)
### API Server (EC2)
- Manual deployment via `scripts/deploy-api.sh` or `scripts/deploy-api-update.sh`
- Server: `3.71.161.51`
- Manual deployment via `./scripts/deploy-api.sh`
- Docker containerized (Node.js 18 Alpine)
- Server: `3.71.161.51`
- Domain: `api.filamenteka.rs`
- Service: `node-api` (systemd)
- IMPORTANT: When deploying API, remember to build for AMD64 Linux (not ARM macOS)
- **Important**: Build for AMD64 architecture when deploying from ARM Mac
### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- User: `filamenteka_admin`
- Database: `filamenteka`
- Migrations in `/database/migrations/`
- Migrations in `/database/migrations/` (numbered sequence)
- Schema in `/database/schema.sql`
- Use `scripts/update-db-via-aws.sh` for running migrations on production
- Connection via `DATABASE_URL` environment variable
## Important Patterns
### API Communication
- All API calls use axios interceptors for auth (`src/services/api.ts`)
- Auth token stored in localStorage
- All API calls use axios with interceptors (`src/services/api.ts`)
- Auth token stored in localStorage as 'adminToken'
- Automatic redirect on 401/403 in admin routes
- Base URL from `NEXT_PUBLIC_API_URL` env variable
### Color Management
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
- Automatic row coloring based on filament color
- Special handling for gradient filaments
- Primary source: `src/data/bambuLabColors.ts` (hex mappings)
- Extended data: `src/data/bambuLabColorsComplete.ts`
- Refill-only colors: `src/data/refillOnlyColors.ts`
- Automatic row coloring based on filament.boja_hex
- Special gradient handling for multi-color filaments
### State Management
- React hooks for local state
- No global state management library
- Data fetching in components with useEffect
- React hooks for local state (no global state library)
- Data fetching with useEffect in components
- Form state managed with controlled components
- Admin auth state in localStorage
### Testing
- Jest + React Testing Library
- Tests in `__tests__/` directory
- Config: `jest.config.js`, `jest.setup.js`
- Coverage goal: >80%
- Run with `npm test` or `npm run test:watch`
- Tests include: component tests, API integration tests, data consistency checks
### Testing Strategy
- Jest + React Testing Library for component tests
- Special `no-mock-data.test.ts` enforces real API usage
- Integration tests connect to real database
- Coverage reports in `/coverage/`
- Test files in `__tests__/` directories
## Environment Variables
@@ -164,45 +176,56 @@ color_requests: {
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
# API Server (.env in /api directory)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=...
ADMIN_PASSWORD=...
# API Server (.env)
DATABASE_URL=postgresql://username:password@host/database
JWT_SECRET=your-secret-key
NODE_ENV=production
PORT=80
PORT=3001
```
## Security Considerations
- Admin routes protected by JWT authentication (24h expiry)
- Password hashing with bcrypt (for future multi-user support)
- JWT authentication for admin routes (24h expiry)
- Password hashing with bcryptjs (10 rounds)
- SQL injection prevention via parameterized queries
- Credential leak detection in pre-commit hooks (`scripts/security/security-check.js`)
- CORS configured to allow all origins (update for production hardening)
- Auth token interceptors handle 401/403 automatically
- Pre-commit hook runs security checks, build tests, and unit tests
- Credential leak detection in pre-commit hooks
- CORS configured for production domains only
- Author mention detection prevents attribution in commits
## Database Operations
When modifying the database:
1. Create migration file in `/database/migrations/` with sequential numbering
2. Test locally first
3. Run migration on production:
- Use `scripts/update-db-via-aws.sh` for remote execution
- Or use `npm run migrate` for local/scripted execution
4. Update corresponding TypeScript types in `/src/types/`
Important database constraints:
- `filaments.boja` has foreign key to `colors.name` (ON UPDATE CASCADE)
- `filaments.kolicina` has check constraint: `kolicina = refill + spulna`
- Always update `colors` table first before adding filaments with new colors
1. Create numbered migration file in `/database/migrations/`
2. Test locally with `npm run migrate`
3. Deploy to production via SSH or migration script
4. Update TypeScript interfaces in `src/types/`
5. Update relevant data files in `src/data/`
## Terraform Infrastructure
Infrastructure as Code in `/terraform/`:
- VPC and networking setup
- EC2 instance for API
- RDS PostgreSQL database
- Application Load Balancer
- ECR for Docker images
- Cloudflare DNS integration
- VPC with public/private subnets
- EC2 instance with Application Load Balancer
- RDS PostgreSQL instance
- ECR for Docker image registry
- Cloudflare DNS integration
- Environment separation (dev/prod)
## Special Considerations
### ARM to AMD64 Builds
When deploying from ARM Mac to AMD64 Linux:
- Docker builds must specify `--platform linux/amd64`
- Deployment scripts handle architecture conversion
- Test builds locally with `docker buildx`
### Color Data Synchronization
- Colors must exist in both database and frontend data files
- Use migration scripts to sync color data
- Validate consistency with data consistency tests
### Sale Management
- Bulk sale operations affect multiple filaments
- Sale countdown timers update in real-time
- Prices automatically calculated with discounts
- Sale end dates trigger automatic deactivation

View File

@@ -28,8 +28,8 @@ describe('UI Features Tests', () => {
// Check for number inputs for quantities
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
expect(adminContent).toContain('Refil');
expect(adminContent).toContain('Špulna');
expect(adminContent).toContain('Refill');
expect(adminContent).toContain('Spulna');
expect(adminContent).toContain('Ukupna količina');
});

View File

@@ -154,43 +154,24 @@ app.post('/api/filaments', authenticateToken, async (req, res) => {
app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
try {
// Ensure refill and spulna are numbers
const refillNum = parseInt(refill) || 0;
const spulnaNum = parseInt(spulna) || 0;
const kolicina = refillNum + spulnaNum;
// Check if sale fields are provided in the request
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
let result;
if (hasSaleFields) {
// Update with sale fields if they are provided
result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
sale_percentage = $9, sale_active = $10,
sale_start_date = $11, sale_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
} else {
// Update without touching sale fields if they are not provided
result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $9 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id]
);
}
const result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
sale_percentage = $9, sale_active = $10,
sale_start_date = $11, sale_end_date = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 RETURNING *`,
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating filament:', error);

View File

@@ -3,7 +3,10 @@
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 ColorRequestSection from '../src/components/ColorRequestSection';
import { Filament } from '../src/types/filament';
import { filamentService } from '../src/services/api';
import { trackEvent } from '../src/components/MatomoAnalytics';
@@ -15,7 +18,7 @@ export default function Home() {
const [darkMode, setDarkMode] = useState(false);
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
@@ -166,112 +169,109 @@ export default function Home() {
</a>
<a
href="tel:+381631031048"
href="tel:+381677102845"
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
Pozovi +381 67 710 2845
</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
</span>
</div>
</div>
{/* 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" />
</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>
{/* 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',
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}
/>
{/* Color Request Section Below Table */}
<div className="mt-16">
<ColorRequestSection />
</div>
</>
)}
{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">
<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">
<div className="flex flex-col sm:flex-row justify-center items-center gap-6">
<div className="text-center sm:text-left">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
<a
href="tel:+381631031048"
<a
href="tel:+381677102845"
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')}
>
<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>
+381 63 103 1048
+381 67 710 2845
</a>
</div>
</div>
</div>
</footer>
{/* Color Request Modal */}
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
</div>
);
}

View File

@@ -32,7 +32,7 @@ const REFILL_ONLY_COLORS = [
// 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';
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
};
// Helper function to check if a filament should be refill-only
@@ -45,10 +45,6 @@ const isRefillOnly = (color: string, finish?: string, type?: string): boolean =>
if (finish === 'Translucent') {
return false;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
return REFILL_ONLY_COLORS.includes(color);
};
@@ -464,8 +460,8 @@ export default function AdminDashboard() {
<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: Finiš (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finiš (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finish (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finish (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 ažurirano</option>
@@ -511,16 +507,16 @@ export default function AdminDashboard() {
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
@@ -870,7 +866,7 @@ function FilamentForm({
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finiš</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finish</label>
<select
name="finish"
value={formData.finish}
@@ -878,7 +874,7 @@ function FilamentForm({
required
className="custom-select 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"
>
<option value="">Izaberi finiš</option>
<option value="">Izaberi finish</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
@@ -949,7 +945,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-green-600 dark:text-green-400">Cena Refila</span>
<span className="text-green-600 dark:text-green-400">Cena Refill</span>
</label>
<input
type="number"
@@ -961,8 +957,8 @@ function FilamentForm({
placeholder="3499"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700'
} text-green-600 dark:text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
/>
@@ -970,7 +966,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-blue-500 dark:text-blue-400">Cena Špulne</span>
<span className="text-blue-500 dark:text-blue-400">Cena Spulna</span>
</label>
<input
type="number"
@@ -982,8 +978,8 @@ function FilamentForm({
placeholder="3999"
disabled={isRefillOnly(formData.boja, formData.finish)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
isRefillOnly(formData.boja, formData.finish)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400'
: 'bg-white dark:bg-gray-700 text-blue-500 dark:text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
}`}
/>
@@ -992,9 +988,9 @@ function FilamentForm({
{/* Quantity inputs for refill, vakuum, and otvoreno */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Refil
Refill
{isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo spulna postoji)</span>
)}
</label>
<input
@@ -1007,8 +1003,8 @@ function FilamentForm({
placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700'
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
@@ -1016,7 +1012,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Špulna
Spulna
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span>
)}
@@ -1031,8 +1027,8 @@ function FilamentForm({
placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700'
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>

View File

@@ -1,77 +0,0 @@
-- Migration: Add new Bambu Lab PLA Matte and PLA Wood colors (2025)
-- This migration adds the new color offerings from Bambu Lab
-- Add new PLA Matte colors to the colors table
INSERT INTO colors (name, hex) VALUES
('Matte Apple Green', '#C6E188'),
('Matte Bone White', '#C8C5B6'),
('Matte Caramel', '#A4845C'),
('Matte Dark Blue', '#042F56'),
('Matte Dark Brown', '#7D6556'),
('Matte Dark Chocolate', '#4A3729'),
('Matte Dark Green', '#68724D'),
('Matte Dark Red', '#BB3D43'),
('Matte Grass Green', '#7CB342'),
('Matte Ice Blue', '#A3D8E1'),
('Matte Lemon Yellow', '#F7D959'),
('Matte Lilac Purple', '#AE96D4'),
('Matte Plum', '#851A52'),
('Matte Sakura Pink', '#E8AFCF'),
('Matte Sky Blue', '#73B2E5'),
('Matte Terracotta', '#A25A37')
ON CONFLICT (name)
DO UPDATE SET hex = EXCLUDED.hex;
-- Add new PLA Wood colors to the colors table
INSERT INTO colors (name, hex) VALUES
('Ochre Yellow', '#BC8B39'),
('White Oak', '#D2CCA2'),
('Clay Brown', '#8E621A')
ON CONFLICT (name)
DO UPDATE SET hex = EXCLUDED.hex;
-- Add PLA Matte filaments (all Refill only - 1 refill each, 0 spool)
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
SELECT
'PLA' as tip,
'Matte' as finish,
c.name as boja,
1 as refill,
0 as spulna,
1 as kolicina,
'3499' as cena
FROM colors c
WHERE c.name IN (
'Matte Apple Green', 'Matte Bone White', 'Matte Caramel',
'Matte Dark Blue', 'Matte Dark Brown', 'Matte Dark Chocolate',
'Matte Dark Green', 'Matte Dark Red', 'Matte Grass Green',
'Matte Ice Blue', 'Matte Lemon Yellow', 'Matte Lilac Purple',
'Matte Plum', 'Matte Sakura Pink', 'Matte Sky Blue', 'Matte Terracotta'
)
AND NOT EXISTS (
SELECT 1 FROM filaments f
WHERE f.tip = 'PLA'
AND f.finish = 'Matte'
AND f.boja = c.name
);
-- Add PLA Wood filaments (all Spool only - 0 refill, 1 spool each)
INSERT INTO filaments (tip, finish, boja, refill, spulna, kolicina, cena)
SELECT
'PLA' as tip,
'Wood' as finish,
c.name as boja,
0 as refill,
1 as spulna,
1 as kolicina,
'3999' as cena
FROM colors c
WHERE c.name IN (
'Ochre Yellow', 'White Oak', 'Clay Brown'
)
AND NOT EXISTS (
SELECT 1 FROM filaments f
WHERE f.tip = 'PLA'
AND f.finish = 'Wood'
AND f.boja = c.name
);

View File

@@ -1,77 +0,0 @@
// Verification script to check consistency between frontend colors and migration
const fs = require('fs');
const path = require('path');
console.log('Verifying color consistency between frontend and backend...\n');
// Read frontend color definitions
const frontendFile = fs.readFileSync(
path.join(__dirname, '../src/data/bambuLabColors.ts'),
'utf8'
);
// Read migration file
const migrationFile = fs.readFileSync(
path.join(__dirname, '../database/migrations/019_add_new_bambu_colors_2025.sql'),
'utf8'
);
// Extract colors from frontend (looking for pattern: 'ColorName': { hex: '#HEXCODE' })
const frontendColorRegex = /'([^']+)':\s*\{\s*hex:\s*'(#[A-F0-9]{6})'/gi;
const frontendColors = {};
let match;
while ((match = frontendColorRegex.exec(frontendFile)) !== null) {
frontendColors[match[1]] = match[2];
}
// Extract new Matte colors from migration
const migrationColorRegex = /\('([^']+)',\s*'(#[A-F0-9]{6})'\)/gi;
const migrationColors = {};
while ((match = migrationColorRegex.exec(migrationFile)) !== null) {
migrationColors[match[1]] = match[2];
}
console.log('New colors added in migration 019:');
console.log('='.repeat(60));
let hasErrors = false;
// Check each migration color exists in frontend with same hex code
Object.keys(migrationColors).forEach(colorName => {
const migrationHex = migrationColors[colorName];
const frontendHex = frontendColors[colorName];
if (!frontendHex) {
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
hasErrors = true;
} else if (frontendHex !== migrationHex) {
console.log(`❌ ERROR: '${colorName}' hex mismatch:`);
console.log(` Frontend: ${frontendHex}`);
console.log(` Migration: ${migrationHex}`);
hasErrors = true;
} else {
console.log(`${colorName}: ${frontendHex}`);
}
});
// Check if PLA Wood colors exist in frontend
console.log('\nPLA Wood colors (should already exist in frontend):');
console.log('='.repeat(60));
const woodColors = ['Ochre Yellow', 'White Oak', 'Clay Brown'];
woodColors.forEach(colorName => {
if (frontendColors[colorName]) {
console.log(`${colorName}: ${frontendColors[colorName]}`);
} else {
console.log(`❌ ERROR: '${colorName}' missing in frontend`);
hasErrors = true;
}
});
console.log('\n' + '='.repeat(60));
if (hasErrors) {
console.log('❌ VERIFICATION FAILED: Inconsistencies detected');
process.exit(1);
} else {
console.log('✓ VERIFICATION PASSED: All colors are consistent');
process.exit(0);
}

View File

@@ -0,0 +1,67 @@
'use client';
import React, { useState } from 'react';
import ColorRequestModal from './ColorRequestModal';
import { trackEvent } from './MatomoAnalytics';
export default function ColorRequestSection() {
const [showModal, setShowModal] = useState(false);
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="text-center">
<div className="flex justify-center mb-6">
<div className="p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<svg className="w-16 h-16 text-purple-600 dark:text-purple-400" 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>
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Zatražite Novu Boju
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
Ne možete pronaći boju koju tražite u našoj ponudi?
Javite nam koju boju želite i mi ćemo je nabaviti za vas!
</p>
<div className="space-y-4">
<button
onClick={() => {
setShowModal(true);
trackEvent('Navigation', 'Request Color', 'Color Request Tab');
}}
className="inline-flex items-center gap-3 px-8 py-4 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-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Pošaljite Zahtev za Boju
</button>
</div>
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">1-3</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za odgovor</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100+</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dostupnih boja</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">7-14</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za nabavku</div>
</div>
</div>
</div>
</div>
<ColorRequestModal isOpen={showModal} onClose={() => setShowModal(false)} />
</div>
);
}

View File

@@ -157,16 +157,16 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}

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 justify-center space-x-4 sm: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

@@ -33,7 +33,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Charcoal': { hex: '#000000' },
'Cherry Pink': { hex: '#E9B6CC' },
'Chocolate': { hex: '#4A3729' },
'Classic Birch': { hex: '#E8D5B7' },
'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' },
@@ -141,25 +140,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Marble': { hex: '#F7F3F0' },
'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' },
// PLA Matte - New Colors (2025)
'Matte Apple Green': { hex: '#C6E188' },
'Matte Bone White': { hex: '#C8C5B6' },
'Matte Caramel': { hex: '#A4845C' },
'Matte Dark Blue': { hex: '#042F56' },
'Matte Dark Brown': { hex: '#7D6556' },
'Matte Dark Chocolate': { hex: '#4A3729' },
'Matte Dark Green': { hex: '#68724D' },
'Matte Dark Red': { hex: '#BB3D43' },
'Matte Grass Green': { hex: '#7CB342' },
'Matte Ice Blue': { hex: '#A3D8E1' },
'Matte Lemon Yellow': { hex: '#F7D959' },
'Matte Lilac Purple': { hex: '#AE96D4' },
'Matte Plum': { hex: '#851A52' },
'Matte Sakura Pink': { hex: '#E8AFCF' },
'Matte Sky Blue': { hex: '#73B2E5' },
'Matte Terracotta': { hex: '#A25A37' },
// Default fallback
'Unknown': { hex: '#CCCCCC' }
};

View File

@@ -39,24 +39,6 @@ export const bambuLabColors = {
"Matte Lime": "#9E9D24",
"Matte Navy": "#1A237E",
"Matte Coral": "#FF5252",
// Matte Colors - New 2025
"Matte Apple Green": "#C6E188",
"Matte Bone White": "#C8C5B6",
"Matte Caramel": "#A4845C",
"Matte Dark Blue": "#042F56",
"Matte Dark Brown": "#7D6556",
"Matte Dark Chocolate": "#4A3729",
"Matte Dark Green": "#68724D",
"Matte Dark Red": "#BB3D43",
"Matte Grass Green": "#7CB342",
"Matte Ice Blue": "#A3D8E1",
"Matte Lemon Yellow": "#F7D959",
"Matte Lilac Purple": "#AE96D4",
"Matte Plum": "#851A52",
"Matte Sakura Pink": "#E8AFCF",
"Matte Sky Blue": "#73B2E5",
"Matte Terracotta": "#A25A37",
// Silk Colors
"Silk White": "#FEFEFE",
@@ -123,11 +105,7 @@ export const colorsByFinish = {
"Matte": [
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
"Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral"
],
"Silk": [
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",

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;