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 # 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 ## Project Overview
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of: Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments with:
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export) - **Frontend**: Next.js 15 app with React 19, TypeScript 5.2.2, and Tailwind CSS (static export)
- **Backend**: Node.js API server with PostgreSQL database - **Backend**: Node.js Express API server with PostgreSQL database
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database) - **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database), managed via Terraform
## Critical Rules ## Critical Rules
- NEVER mention ANY author in commits. No author tags or attribution - 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 - Keep commit messages clean and professional with NO attribution
- Build for AMD64 Linux when deploying (development is on ARM macOS) - Build for AMD64 Linux when deploying (development is on ARM macOS)
- Always run security checks before commits - Always run security checks before commits
- NEVER use mock data - all tests must use real APIs/data
## Common Commands ## Common Commands
```bash ```bash
# Development # Development
npm run dev # Start Next.js development server (port 3000) npm run dev # Start Next.js development server (port 3000)
npm run build # Build static export to /out directory npm run build # Build static export to /out directory
npm run lint # Run ESLint npm run lint # Run ESLint
npm test # Run Jest tests npm test # Run Jest tests (includes no-mock-data validation)
npm run test:watch # Run Jest in watch mode
# Security & Quality # Security & Quality
npm run security:check # Check for credential leaks npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds npm run test:build # Test if build succeeds
./scripts/pre-commit.sh # Runs security, build, and test checks (use before commits) ./scripts/pre-commit.sh # Full pre-commit validation (security, build, tests)
# Database Migrations # Database Migrations
npm run migrate # Run pending migrations npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history 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 ## Architecture
### Frontend Structure (Next.js App Router) ### Frontend Structure (Next.js App Router)
- `/app` - Next.js 13+ app directory structure ```
- `/page.tsx` - Public filament inventory table /app
- `/upadaj` - Admin panel (password protected) ├── page.tsx # Public filament inventory table
- `/page.tsx` - Admin login ├── layout.tsx # Root layout with providers
- `/dashboard/page.tsx` - Filament CRUD operations └── upadaj/ # Admin panel (JWT protected)
- `/colors/page.tsx` - Color management ├── page.tsx # Admin login
- `/requests/page.tsx` - Customer color requests ├── dashboard/page.tsx # Filament CRUD operations
- `/src` - Source files ├── colors/page.tsx # Color management
- `/components` - React components └── requests/page.tsx # Color request management
- `/services/api.ts` - Axios instance with auth interceptors ```
- `/data` - Color definitions (bambuLabColors.ts, bambuLabColorsComplete.ts)
- `/types` - TypeScript type definitions
### API Structure ### API Structure
- `/api` - Node.js Express server (runs on EC2, port 80) ```
- `/server.js` - Main Express server with all routes inline /api
- Database: PostgreSQL on AWS RDS ├── server.js # Express server (port 3001)
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests` ├── 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 ### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering - `FilamentTableV2` - Main inventory display with sorting/filtering/searching
- `SaleManager` - Bulk sale management interface - `SaleManager` - Bulk sale management with countdown timers
- `ColorCell` - Smart color rendering with gradient support - `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system - `EnhancedFilters` - Multi-criteria filtering system
- `ColorRequestForm` - Customer color request form - `ColorRequestModal` - Public color request form
- `ColorRequestModal` - Modal for color requests
### Data Models ### Data Models
#### Filament Schema (PostgreSQL) #### Filament Schema (PostgreSQL)
```sql ```sql
filaments: { filaments: {
id: UUID, id: UUID PRIMARY KEY,
tip: VARCHAR(50), # Material type (PLA, PETG, ABS) tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk) 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 refill: INTEGER, # Refill spool count
spulna: INTEGER, # Regular spool count spulna: INTEGER, # Regular spool count
kolicina: INTEGER, # Total quantity (refill + spulna) kolicina: INTEGER, # Total quantity (refill + spulna)
cena: VARCHAR(50), # Price cena: VARCHAR(50), # Price string
sale_active: BOOLEAN, # Sale status sale_active: BOOLEAN, # Sale status
sale_percentage: INTEGER,# Sale discount sale_percentage: INTEGER,# Sale discount percentage
sale_end_date: TIMESTAMP # Sale expiry sale_end_date: TIMESTAMP # Sale expiry
} }
``` ```
@@ -89,23 +101,22 @@ filaments: {
#### Color Schema #### Color Schema
```sql ```sql
colors: { colors: {
id: UUID, id: UUID PRIMARY KEY,
name: VARCHAR(100), # Color name (must match filament.boja) name: VARCHAR(100) UNIQUE, # Color name (must match filament.boja)
hex: VARCHAR(7), # Hex color code hex: VARCHAR(7), # Hex color code
cena_refill: INTEGER, # Refill price (default: 3499) cena_refill: INTEGER, # Refill price (default: 3499)
cena_spulna: INTEGER # Regular price (default: 3999) cena_spulna: INTEGER # Regular price (default: 3999)
} }
``` ```
#### Color Requests Schema #### Color Requests Schema
```sql ```sql
color_requests: { color_requests: {
id: UUID, id: UUID PRIMARY KEY,
color_name: VARCHAR(255), # Requested color name color_name: VARCHAR(100),
message: TEXT, # Customer message email: VARCHAR(255),
contact_name: VARCHAR(255), # Customer name (required) message: TEXT,
contact_phone: VARCHAR(50), # Customer phone (required) status: VARCHAR(50), # pending/approved/rejected
status: VARCHAR(20), # Status: pending, reviewed, fulfilled
created_at: TIMESTAMP created_at: TIMESTAMP
} }
``` ```
@@ -115,48 +126,49 @@ color_requests: {
### Frontend (AWS Amplify) ### Frontend (AWS Amplify)
- Automatic deployment on push to main branch - Automatic deployment on push to main branch
- Build output: Static files in `/out` directory - Build output: Static files in `/out` directory
- Build command: `npm run build`
- Config: `amplify.yml`, `next.config.js` (output: 'export') - Config: `amplify.yml`, `next.config.js` (output: 'export')
- Security check runs during build (amplify.yml preBuild phase)
### API Server (EC2) ### API Server (EC2)
- Manual deployment via `scripts/deploy-api.sh` or `scripts/deploy-api-update.sh` - Manual deployment via `./scripts/deploy-api.sh`
- Server: `3.71.161.51` - Docker containerized (Node.js 18 Alpine)
- Server: `3.71.161.51`
- Domain: `api.filamenteka.rs` - Domain: `api.filamenteka.rs`
- Service: `node-api` (systemd) - **Important**: Build for AMD64 architecture when deploying from ARM Mac
- IMPORTANT: When deploying API, remember to build for AMD64 Linux (not ARM macOS)
### Database (RDS PostgreSQL) ### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com` - Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- User: `filamenteka_admin` - Migrations in `/database/migrations/` (numbered sequence)
- Database: `filamenteka`
- Migrations in `/database/migrations/`
- Schema in `/database/schema.sql` - 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 ## Important Patterns
### API Communication ### API Communication
- All API calls use axios interceptors for auth (`src/services/api.ts`) - All API calls use axios with interceptors (`src/services/api.ts`)
- Auth token stored in localStorage - Auth token stored in localStorage as 'adminToken'
- Automatic redirect on 401/403 in admin routes - Automatic redirect on 401/403 in admin routes
- Base URL from `NEXT_PUBLIC_API_URL` env variable
### Color Management ### Color Management
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts` - Primary source: `src/data/bambuLabColors.ts` (hex mappings)
- Automatic row coloring based on filament color - Extended data: `src/data/bambuLabColorsComplete.ts`
- Special handling for gradient filaments - Refill-only colors: `src/data/refillOnlyColors.ts`
- Automatic row coloring based on filament.boja_hex
- Special gradient handling for multi-color filaments
### State Management ### State Management
- React hooks for local state - React hooks for local state (no global state library)
- No global state management library - Data fetching with useEffect in components
- Data fetching in components with useEffect - Form state managed with controlled components
- Admin auth state in localStorage
### Testing ### Testing Strategy
- Jest + React Testing Library - Jest + React Testing Library for component tests
- Tests in `__tests__/` directory - Special `no-mock-data.test.ts` enforces real API usage
- Config: `jest.config.js`, `jest.setup.js` - Integration tests connect to real database
- Coverage goal: >80% - Coverage reports in `/coverage/`
- Run with `npm test` or `npm run test:watch` - Test files in `__tests__/` directories
- Tests include: component tests, API integration tests, data consistency checks
## Environment Variables ## Environment Variables
@@ -164,45 +176,56 @@ color_requests: {
# Frontend (.env.local) # Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
# API Server (.env in /api directory) # API Server (.env)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka DATABASE_URL=postgresql://username:password@host/database
JWT_SECRET=... JWT_SECRET=your-secret-key
ADMIN_PASSWORD=...
NODE_ENV=production NODE_ENV=production
PORT=80 PORT=3001
``` ```
## Security Considerations ## Security Considerations
- Admin routes protected by JWT authentication (24h expiry) - JWT authentication for admin routes (24h expiry)
- Password hashing with bcrypt (for future multi-user support) - Password hashing with bcryptjs (10 rounds)
- SQL injection prevention via parameterized queries - SQL injection prevention via parameterized queries
- Credential leak detection in pre-commit hooks (`scripts/security/security-check.js`) - Credential leak detection in pre-commit hooks
- CORS configured to allow all origins (update for production hardening) - CORS configured for production domains only
- Auth token interceptors handle 401/403 automatically - Author mention detection prevents attribution in commits
- Pre-commit hook runs security checks, build tests, and unit tests
## Database Operations ## Database Operations
When modifying the database: When modifying the database:
1. Create migration file in `/database/migrations/` with sequential numbering 1. Create numbered migration file in `/database/migrations/`
2. Test locally first 2. Test locally with `npm run migrate`
3. Run migration on production: 3. Deploy to production via SSH or migration script
- Use `scripts/update-db-via-aws.sh` for remote execution 4. Update TypeScript interfaces in `src/types/`
- Or use `npm run migrate` for local/scripted execution 5. Update relevant data files in `src/data/`
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
## Terraform Infrastructure ## Terraform Infrastructure
Infrastructure as Code in `/terraform/`: Infrastructure as Code in `/terraform/`:
- VPC and networking setup - VPC with public/private subnets
- EC2 instance for API - EC2 instance with Application Load Balancer
- RDS PostgreSQL database - RDS PostgreSQL instance
- Application Load Balancer - ECR for Docker image registry
- ECR for Docker images - Cloudflare DNS integration
- 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 // Check for number inputs for quantities
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/); expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/); expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
expect(adminContent).toContain('Refil'); expect(adminContent).toContain('Refill');
expect(adminContent).toContain('Špulna'); expect(adminContent).toContain('Spulna');
expect(adminContent).toContain('Ukupna količina'); 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) => { app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const { id } = req.params; 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; const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body;
try { try {
// Ensure refill and spulna are numbers // Ensure refill and spulna are numbers
const refillNum = parseInt(refill) || 0; const refillNum = parseInt(refill) || 0;
const spulnaNum = parseInt(spulna) || 0; const spulnaNum = parseInt(spulna) || 0;
const kolicina = refillNum + spulnaNum; const kolicina = refillNum + spulnaNum;
// Check if sale fields are provided in the request const result = await pool.query(
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body || `UPDATE filaments
'sale_start_date' in req.body || 'sale_end_date' in req.body; SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
let result; sale_percentage = $9, sale_active = $10,
if (hasSaleFields) { sale_start_date = $11, sale_end_date = $12,
// Update with sale fields if they are provided updated_at = CURRENT_TIMESTAMP
result = await pool.query( WHERE id = $13 RETURNING *`,
`UPDATE filaments [tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
SET tip = $1, finish = $2, boja = $3, boja_hex = $4, sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
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]
);
}
res.json(result.rows[0]); res.json(result.rows[0]);
} catch (error) { } catch (error) {
console.error('Error updating filament:', error); console.error('Error updating filament:', error);

View File

@@ -3,7 +3,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FilamentTableV2 } from '../src/components/FilamentTableV2'; import { FilamentTableV2 } from '../src/components/FilamentTableV2';
import { SaleCountdown } from '../src/components/SaleCountdown'; 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 { Filament } from '../src/types/filament';
import { filamentService } from '../src/services/api'; import { filamentService } from '../src/services/api';
import { trackEvent } from '../src/components/MatomoAnalytics'; import { trackEvent } from '../src/components/MatomoAnalytics';
@@ -15,7 +18,7 @@ export default function Home() {
const [darkMode, setDarkMode] = useState(false); const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [resetKey, setResetKey] = useState(0); const [resetKey, setResetKey] = useState(0);
const [showColorRequestModal, setShowColorRequestModal] = useState(false); const [activeTab, setActiveTab] = useState('filaments');
// Removed V1/V2 toggle - now only using V2 // Removed V1/V2 toggle - now only using V2
// Initialize dark mode from localStorage after mounting // Initialize dark mode from localStorage after mounting
@@ -166,112 +169,109 @@ export default function Home() {
</a> </a>
<a <a
href="tel:+381631031048" href="tel:+381677102845"
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')} 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" 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"> <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" /> <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> </svg>
Pozovi +381 63 103 1048 Pozovi +381 67 710 2845
</a> </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> </div>
<SaleCountdown {/* Tabs Navigation */}
hasActiveSale={filaments.some(f => f.sale_active === true)} <div className="mb-8">
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)} <TabbedNavigation
saleEndDate={(() => { tabs={[
const activeSales = filaments.filter(f => f.sale_active === true && f.sale_end_date); {
if (activeSales.length === 0) return null; id: 'filaments',
const latestSale = activeSales.reduce((latest, current) => { label: 'Filamenti',
if (!latest.sale_end_date) return current; icon: (
if (!current.sale_end_date) return latest; <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest; <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" />
}).sale_end_date; </svg>
return latestSale; )
})()} },
/> {
id: 'printers',
{/* Pricing Information */} label: 'Štampači',
<div className="mb-6 space-y-4"> icon: (
{/* Reusable Spool Price Notice */} <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> <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" />
<div className="flex items-center justify-center gap-2"> </svg>
<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"> id: 'gear',
Cena višekratne špulne: 499 RSD label: 'Oprema',
</span> icon: (
</div> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <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" />
{/* Selling by Grams Notice */} </svg>
<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"> activeTab={activeTab}
<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" /> onTabChange={setActiveTab}
</svg> />
<span className="text-green-800 dark:text-green-200 font-semibold">
Prodaja filamenta na grame - idealno za testiranje materijala ili manje projekte
</span>
</div>
<div className="flex flex-wrap justify-center gap-4 text-sm">
<span className="text-green-700 dark:text-green-300 font-medium">50gr - 299 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">100gr - 499 RSD</span>
<span className="text-gray-400 dark:text-gray-600"></span>
<span className="text-green-700 dark:text-green-300 font-medium">200gr - 949 RSD</span>
</div>
</div>
</div>
</div> </div>
<FilamentTableV2 {/* Tab Content */}
key={resetKey} {activeTab === 'filaments' && (
filaments={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> </main>
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16"> <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="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="flex flex-col sm:flex-row justify-center items-center gap-6">
<div className="text-center"> <div className="text-center sm:text-left">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
<a <a
href="tel:+381631031048" 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" className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')} onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
+381 63 103 1048 +381 67 710 2845
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
{/* Color Request Modal */}
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
</div> </div>
); );
} }

View File

@@ -32,7 +32,7 @@ const REFILL_ONLY_COLORS = [
// Helper function to check if a filament is spool-only // Helper function to check if a filament is spool-only
const isSpoolOnly = (finish?: string, type?: string): boolean => { 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 // 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') { if (finish === 'Translucent') {
return false; return false;
} }
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
return REFILL_ONLY_COLORS.includes(color); 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="boja-desc">Sortiraj po: Boja (Z-A)</option>
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option> <option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option> <option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finiš (A-Z)</option> <option value="finish-asc">Sortiraj po: Finish (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finiš (Z-A)</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-desc">Sortiraj po: Poslednje dodano</option>
<option value="created_at-asc">Sortiraj po: Prvo dodano</option> <option value="created_at-asc">Sortiraj po: Prvo dodano</option>
<option value="updated_at-desc">Sortiraj po: Poslednje ažurirano</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' ? '↑' : '↓')} Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th> </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"> <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>
<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"> <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' ? '↑' : '↓')} Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th> </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"> <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>
<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"> <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>
<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"> <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' ? '↑' : '↓')} Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
@@ -870,7 +866,7 @@ function FilamentForm({
</div> </div>
<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 <select
name="finish" name="finish"
value={formData.finish} value={formData.finish}
@@ -878,7 +874,7 @@ function FilamentForm({
required 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" 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 => ( {(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option> <option key={finish} value={finish}>{finish}</option>
))} ))}
@@ -949,7 +945,7 @@ function FilamentForm({
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300"> <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> </label>
<input <input
type="number" type="number"
@@ -961,8 +957,8 @@ function FilamentForm({
placeholder="3499" placeholder="3499"
disabled={isSpoolOnly(formData.finish, formData.tip)} disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip) isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700' : '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`} } 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> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300"> <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> </label>
<input <input
type="number" type="number"
@@ -982,8 +978,8 @@ function FilamentForm({
placeholder="3999" placeholder="3999"
disabled={isRefillOnly(formData.boja, formData.finish)} disabled={isRefillOnly(formData.boja, formData.finish)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish) isRefillOnly(formData.boja, formData.finish)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed text-gray-400' ? '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' : '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 */} {/* Quantity inputs for refill, vakuum, and otvoreno */}
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Refil Refill
{isSpoolOnly(formData.finish, formData.tip) && ( {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> </label>
<input <input
@@ -1007,8 +1003,8 @@ function FilamentForm({
placeholder="0" placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)} disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isSpoolOnly(formData.finish, formData.tip) isSpoolOnly(formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700' : 'bg-white dark:bg-gray-700'
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`} } text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/> />
@@ -1016,7 +1012,7 @@ function FilamentForm({
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300"> <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) && ( {isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span> <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" placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)} 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 ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip) isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed' ? 'bg-gray-100 dark:bg-gray-600 cursor-not-allowed'
: 'bg-white dark:bg-gray-700' : 'bg-white dark:bg-gray-700'
} text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`} } 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' ? '↑' : '↓')} Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th> </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"> <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>
<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"> <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' ? '↑' : '↓')} Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th> </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"> <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>
<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"> <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>
<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"> <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' ? '↑' : '↓')} 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' }, 'Charcoal': { hex: '#000000' },
'Cherry Pink': { hex: '#E9B6CC' }, 'Cherry Pink': { hex: '#E9B6CC' },
'Chocolate': { hex: '#4A3729' }, 'Chocolate': { hex: '#4A3729' },
'Classic Birch': { hex: '#E8D5B7' },
'Clay Brown': { hex: '#8E621A' }, 'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' }, 'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' }, 'Clear Black': { hex: '#5A5161' },
@@ -141,25 +140,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Marble': { hex: '#F7F3F0' }, 'White Marble': { hex: '#F7F3F0' },
'White Oak': { hex: '#D2CCA2' }, 'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' }, '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 // Default fallback
'Unknown': { hex: '#CCCCCC' } 'Unknown': { hex: '#CCCCCC' }
}; };

View File

@@ -39,24 +39,6 @@ export const bambuLabColors = {
"Matte Lime": "#9E9D24", "Matte Lime": "#9E9D24",
"Matte Navy": "#1A237E", "Matte Navy": "#1A237E",
"Matte Coral": "#FF5252", "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 Colors
"Silk White": "#FEFEFE", "Silk White": "#FEFEFE",
@@ -123,11 +105,7 @@ export const colorsByFinish = {
"Matte": [ "Matte": [
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green", "Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey", "Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral", "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"
], ],
"Silk": [ "Silk": [
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green", "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; export default api;