24 Commits

Author SHA1 Message Date
DaX
b7f5417e23 Move dashboard to root level route
- Dashboard now accessible at /dashboard instead of /upadaj/dashboard
- Updated all navigation links to use new /dashboard route
- Login page still at /upadaj, redirects to /dashboard on success
- Colors and requests remain under /upadaj prefix
- Updated test files to reference new dashboard path
2025-11-19 19:13:48 +01:00
DaX
17edfc8794 Fix CloudFront routing and TypeScript type safety
- Update CloudFront Function to handle Next.js static export .html files
- Fix TypeScript interface for color request service (add required user_phone field)
- Update ColorRequestForm component to include phone field
2025-11-19 18:56:08 +01:00
DaX
f6f9da9c5b Add bulk price editing features and fix quantity update price preservation
- Fix FilamentForm to preserve prices when updating quantities
  - Add isInitialLoad flag to prevent price override on form load
  - Only update prices when color is actively changed, not on initial render

- Add bulk filament price editor to dashboard
  - Filter by material and finish
  - Set refill and/or spool prices for multiple filaments
  - Preview changes before applying
  - Update all filtered filaments at once

- Add bulk color price editor to colors page
  - Edit prices for all colors in one interface
  - Global price application with search filtering
  - Individual price editing with change tracking

- Add auto-kill script for dev server
  - Kill existing processes on ports 3000/3001 before starting
  - Prevent "port already in use" errors
  - Clean start every time npm run dev is executed
2025-11-18 19:14:01 +01:00
DaX
b1dfa2352a Add 76 missing Bambu Lab colors and expand filament type support
- Added comprehensive color migration with 76 new Bambu Lab colors
- Includes glow, metallic, sparkle, gradient, ABS, and translucent variants
- Added PAHT material type with CF and Bez Finisha finish support
- Added Tough+ finish option for PLA filaments
- Fixed refill/spulna restrictions for specific combinations:
  - ABS GF Yellow/Orange now refill-only
  - TPU 95A HF now refill-only
  - Removed spool restrictions for Galaxy and Basic finishes
- Updated frontend color mappings with all new colors
- All colors now available in admin panel for inventory management
2025-11-13 07:02:06 +01:00
DaX
987039b0f7 Optimize frontend deployment with proper cache headers
Update deployment script to set appropriate cache-control headers:
- HTML files: no-cache to prevent stale content
- Next.js static assets: 1 year cache with immutable flag
- Other assets: 1 day cache for optimal performance

This resolves chronic caching issues with admin panel updates.
2025-10-31 03:39:25 +01:00
DaX
6bc1c8d16d Add CloudFront Function for directory index routing
- Create CloudFront Function to rewrite directory requests to index.html
- Fix admin login page routing issue (/upadaj -> /upadaj/index.html)
- Attach function to CloudFront distribution default cache behavior
- Enables proper routing for all admin pages without .html extension
2025-10-31 02:54:46 +01:00
DaX
d3e001707b Fix admin panel authentication and navigation for static export
- Replace router.push with window.location.href for reliable redirects
- Fix auth check to wait for component mount before accessing localStorage
- Ensure proper redirect after successful login
- Fix redirect behavior on all admin pages (dashboard, colors, requests)
2025-10-31 02:45:47 +01:00
DaX
c0ca1e4bb3 Fix CloudFront domain configuration and add missing color definitions
- Configure CloudFront to accept filamenteka.rs with ACM SSL certificate
- Add Cloudflare Transform Rule for Host header rewriting
- Fix Matte Grass Green hex code (#7CB342 -> #61C680)
- Add PLA Wood colors: Ochre Yellow, White Oak, Clay Brown
- Add script for managing color definitions in database
2025-10-31 02:40:03 +01:00
DaX
1ce127c51c Remove deprecated Amplify resources after CloudFront migration
- Comment out Amplify app, branches, and domain association
- Remove Amplify-related outputs
- Add migration notes for reference
- Amplify app deleted from AWS (dd6qls201bf9n)
2025-10-31 02:07:14 +01:00
DaX
fc95dc4ed2 Migrate frontend from Amplify to CloudFront + S3
- Add CloudFront distribution with S3 origin
- Configure S3 bucket with website hosting
- Add Origin Access Control (OAC) for security
- Configure SPA error handling (404/403 -> index.html)
- Add Cloudflare DNS records (commented out due to invalid token)
- Add deployment script for future updates
- Update outputs to include CloudFront info
2025-10-31 02:04:47 +01:00
DaX
543e51cc3c Add 19 new Bambu Lab colors and fix sale banner display
Added 16 new PLA Matte refill-only colors and 3 PLA Wood spool-only colors.
Updated admin panel to automatically handle Matte prefix and Wood finish.
Fixed sale banner not displaying due to expired sale dates.
Updated all active sales to expire in 7 days (November 7, 2025).
2025-10-31 00:55:43 +01:00
DaX
56a21b27fe Add new Bambu Lab colors and update documentation
Add three new filament colors to support Wood and Matte finishes:
- Classic Birch (PLA Wood) - #E8D5B7
- Rosewood (PLA Wood) - #472A22
- Desert Tan (PLA Matte) - #E8DBB7

Update CLAUDE.md with improved documentation:
- Enhanced architecture details
- Added color requests schema
- Expanded deployment and database sections
- Added testing and security details
2025-10-07 17:57:34 +02:00
DaX
f99a132e7b Center phone number in footer 2025-09-30 15:01:13 +02:00
DaX
990221792a Translate table headers to Serbian (Finish -> Finiš, Spulna -> Špulna, Refill -> Refil) 2025-09-30 14:13:27 +02:00
DaX
6f75abf6ee Add selling by grams pricing information 2025-09-30 14:08:30 +02:00
DaX
01a24e781b Fix sale discount reset when updating filament quantities
Preserve sale fields when updating filament basic data
2025-09-30 11:58:45 +02:00
DaX
92b186b6bc Add reusable spool price notice above table 2025-09-30 10:57:53 +02:00
DaX
06b0a20bef Update phone number to 0631031048 2025-09-24 18:17:43 +02:00
DaX
747d15f1c3 Make email and phone fields required in color requests 2025-08-29 12:44:00 +02:00
DaX
6d534352b2 Add phone field to color request form and database 2025-08-29 12:41:34 +02:00
DaX
9f2dade0e3 Fix styling issues in color requests admin panel
- Add Serbian status labels instead of English status names
- Fix dark mode colors for better contrast
- Update date formatting to use Serbian locale
- Fix request count display to show proper total
- Improve text colors consistency across light/dark themes
- Tested all API endpoints (create, read, update, delete) - working correctly
- Build succeeds without errors
2025-08-06 00:20:26 +02:00
DaX
fd3ba36ae2 Add color request feature with modal and Safari styling fixes
- Implement color request modal popup instead of separate page
- Add Serbian translations throughout
- Fix Safari form styling issues with custom CSS
- Add 'Other' option to material and finish dropdowns
- Create admin panel for managing color requests
- Add database migration for color_requests table
- Implement API endpoints for color request management
2025-08-05 23:34:35 +02:00
DaX
52f93df34a Merge branch 'improvement' 2025-08-05 23:09:14 +02:00
DaX
470cf63b83 Merge pull request #1 from daxdax89/improvement
Improvement
2025-07-21 12:16:31 +02:00
33 changed files with 4596 additions and 2118 deletions

167
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance for AI-assisted development in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
@@ -25,17 +25,21 @@ 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
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 (scripts/security/security-check.js)
npm run test:build # Test if build succeeds npm run test:build # Test if build succeeds without creating files
./scripts/pre-commit.sh # Runs security, build, and test checks (triggered by husky)
# Database Migrations # Database Migrations
npm run migrate # Run pending migrations npm run migrate # Run pending migrations locally
npm run migrate:clear # Clear migration history npm run migrate:clear # Clear migration history (development only)
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
# Pre-commit Hook # Deployment
./scripts/pre-commit.sh # Runs security, build, and test checks scripts/deploy-api-update.sh # Deploy API to EC2 via AWS SSM (recommended)
scripts/deploy-frontend.sh # Manual frontend deployment helper
``` ```
## Architecture ## Architecture
@@ -47,18 +51,26 @@ npm run migrate:clear # Clear migration history
- `/page.tsx` - Admin login - `/page.tsx` - Admin login
- `/dashboard/page.tsx` - Filament CRUD operations - `/dashboard/page.tsx` - Filament CRUD operations
- `/colors/page.tsx` - Color management - `/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
### API Structure ### API Structure
- `/api` - Node.js Express server (runs on EC2) - `/api` - Node.js Express server (runs on EC2, port 80)
- `/server.js` - Main server file - `/server.js` - Main Express server with all routes inline
- `/routes` - API endpoints for filaments, colors, auth
- Database: PostgreSQL on AWS RDS - Database: PostgreSQL on AWS RDS
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
### Key Components ### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering - `FilamentTableV2` - Main inventory display with sorting/filtering
- `SaleManager` - Bulk sale management interface - `SaleManager` - Bulk sale management interface
- `ColorCell` - Smart color rendering with gradient support - `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system - `EnhancedFilters` - Advanced filtering system
- `ColorRequestForm` - Customer color request form
- `ColorRequestModal` - Modal for color requests
### Data Models ### Data Models
@@ -90,44 +102,75 @@ colors: {
} }
``` ```
#### 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
created_at: TIMESTAMP
}
```
## Deployment ## Deployment
### 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
- 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` - Deployment via `scripts/deploy-api-update.sh` (uses AWS SSM to push updates)
- Server: `3.71.161.51` - Instance ID: `i-03956ecf32292d7d9`
- Server IP: `3.71.161.51`
- Domain: `api.filamenteka.rs` - Domain: `api.filamenteka.rs`
- Service: `node-api` (systemd)
- Deploy script pulls from GitHub main branch and restarts service
- 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`
- Database: `filamenteka`
- Migrations in `/database/migrations/` - 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
## Important Patterns ## Important Patterns
### API Communication ### API Communication
- All API calls use axios interceptors for auth (`src/services/api.ts`) - All API calls organized in service modules (`src/services/api.ts`)
- Auth token stored in localStorage - Services: `authService`, `colorService`, `filamentService`, `colorRequestService`
- Automatic redirect on 401/403 in admin routes - Axios instance with interceptors for automatic auth token injection
- Auth token stored in localStorage with 24h expiry
- Automatic redirect on 401/403 in admin routes (via response interceptor)
- Cache busting on filament fetches with timestamp query param
### Color Management ### Color Management
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts` - Frontend color mappings in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
- Automatic row coloring based on filament color - Database color definitions in `colors` table with pricing (cena_refill, cena_spulna)
- Special handling for gradient filaments - ColorMapping interface supports both solid colors (hex string) and gradients (hex array)
- Automatic row coloring via ColorCell component based on filament.boja
- Special handling for gradient filaments (e.g., Cotton Candy Cloud)
- Foreign key constraint: filaments.boja → colors.name (ON UPDATE CASCADE)
### State Management ### State Management
- React hooks for local state - React hooks for local component state
- No global state management library - No global state management library
- Data fetching in components with useEffect - Data fetching in components with useEffect
- Service layer handles all API interactions
### Testing ### Testing
- Jest + React Testing Library - Jest + React Testing Library
- Tests in `__tests__/` directory - Tests in `__tests__/` directory
- Config: `jest.config.js`, `jest.setup.js`
- Coverage goal: >80% - Coverage goal: >80%
- Run with `npm test` or `npm run test:watch`
- Tests include: component tests, API integration tests, data consistency checks
## Environment Variables ## Environment Variables
@@ -135,34 +178,82 @@ colors: {
# 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 # API Server (.env in /api directory)
DATABASE_URL=postgresql://... DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=... JWT_SECRET=...
ADMIN_PASSWORD=...
NODE_ENV=production NODE_ENV=production
PORT=80
``` ```
## Security Considerations ## Security Considerations
- Admin routes protected by JWT authentication - **Authentication**: JWT tokens with 24h expiry (hardcoded admin user for now)
- Password hashing with bcrypt - **Password**: Stored in environment variable ADMIN_PASSWORD (bcrypt ready for multi-user)
- SQL injection prevention via parameterized queries - **SQL Injection**: Prevented via parameterized queries in all database operations
- Credential leak detection in pre-commit hooks - **Credential Leak Detection**: Pre-commit hook runs `scripts/security/security-check.js`
- CORS configured for production domains only - **CORS**: Currently allows all origins (origin: true) - consider hardening for production
- **Auth Interceptors**: Automatic 401/403 handling with redirect to login
- **Pre-commit Checks**: Husky runs `scripts/pre-commit.sh` which executes:
1. Author mention check (blocks commits with attribution)
2. Security check (credential leaks)
3. Build test (ensures code compiles)
4. Unit tests (Jest with --passWithNoTests)
## Database Operations ## Development Workflows
### Adding New Colors
1. Add color to database via admin panel (`/upadaj/colors`) OR via migration
2. Update frontend color mappings in `src/data/bambuLabColors.ts`:
```typescript
'Color Name': { hex: '#HEXCODE' } // Solid color
'Gradient Name': { hex: ['#HEX1', '#HEX2'], isGradient: true } // Gradient
```
3. Color names must match exactly between database and filament.boja
### Database Migrations
When modifying the database: When modifying the database:
1. Create migration file in `/database/migrations/` 1. Create migration file in `/database/migrations/` with sequential numbering (e.g., `019_add_new_feature.sql`)
2. Test locally first 2. Test locally first with `npm run migrate`
3. Run migration on production via scripts 3. Run migration on production:
4. Update corresponding TypeScript types - Use `scripts/update-db-via-aws.sh` for remote execution via EC2
- Or SSH to EC2 and run migration directly
4. Update corresponding TypeScript types in `/src/types/`
## Terraform Infrastructure 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
### API Deployment Workflow
1. Make changes to `/api/server.js`
2. Commit and push to main branch
3. Run `./scripts/deploy-api-update.sh` (uses AWS SSM to pull from GitHub and restart)
4. Verify deployment: `curl https://api.filamenteka.rs/` should return `{"status":"ok"}`
### Frontend Deployment Workflow
1. Make changes to app/components/pages
2. Test locally: `npm run dev`
3. Run pre-commit checks: `./scripts/pre-commit.sh`
4. Commit and push to main branch
5. AWS Amplify automatically builds and deploys (monitors main branch)
## Infrastructure
### Terraform (IaC)
Infrastructure as Code in `/terraform/`: Infrastructure as Code in `/terraform/`:
- VPC and networking setup - **VPC**: `vpc.tf` - Network setup with subnets and routing
- EC2 instance for API - **EC2**: `ec2-api.tf` - API server instance (i-03956ecf32292d7d9)
- RDS PostgreSQL database - **RDS**: `rds.tf` - PostgreSQL database
- Application Load Balancer - **ALB**: `alb.tf` - Application Load Balancer
- ECR for Docker images - **ECR**: `ecr.tf` - Docker image registry
- Cloudflare DNS integration - **CloudFront**: `cloudfront-frontend.tf` - Frontend CDN distribution
- **Cloudflare**: `cloudflare-api.tf` - DNS integration for api.filamenteka.rs
- **Configuration**: `variables.tf`, `outputs.tf`, `main.tf`
### Current Stack
- **Frontend**: AWS CloudFront + S3 (static Next.js export)
- **API**: EC2 instance running Node.js/Express with systemd service
- **Database**: AWS RDS PostgreSQL (eu-central-1)
- **DNS**: Cloudflare for api.filamenteka.rs
- **Deployment**: AWS Amplify for frontend, SSM for API updates

View File

@@ -18,7 +18,7 @@ describe('No Mock Data Tests', () => {
it('should use API service in all components', () => { it('should use API service in all components', () => {
const pagePath = join(process.cwd(), 'app', 'page.tsx'); const pagePath = join(process.cwd(), 'app', 'page.tsx');
const adminPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const pageContent = readFileSync(pagePath, 'utf-8'); const pageContent = readFileSync(pagePath, 'utf-8');
const adminContent = readFileSync(adminPath, 'utf-8'); const adminContent = readFileSync(adminPath, 'utf-8');

View File

@@ -3,7 +3,7 @@ import { join } from 'path';
describe('UI Features Tests', () => { describe('UI Features Tests', () => {
it('should have color hex input in admin form', () => { it('should have color hex input in admin form', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const adminContent = readFileSync(adminDashboardPath, 'utf-8'); const adminContent = readFileSync(adminDashboardPath, 'utf-8');
// Check for color input // Check for color input
@@ -22,19 +22,19 @@ describe('UI Features Tests', () => {
}); });
it('should have number inputs for quantity fields', () => { it('should have number inputs for quantity fields', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const adminContent = readFileSync(adminDashboardPath, 'utf-8'); const adminContent = readFileSync(adminDashboardPath, 'utf-8');
// 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('Refill'); expect(adminContent).toContain('Refil');
expect(adminContent).toContain('Spulna'); expect(adminContent).toContain('Špulna');
expect(adminContent).toContain('Ukupna količina'); expect(adminContent).toContain('Ukupna količina');
}); });
it('should have number input for quantity', () => { it('should have number input for quantity', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const adminContent = readFileSync(adminDashboardPath, 'utf-8'); const adminContent = readFileSync(adminDashboardPath, 'utf-8');
// Check for number input // Check for number input
@@ -44,7 +44,7 @@ describe('UI Features Tests', () => {
}); });
it('should have predefined material options', () => { it('should have predefined material options', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const adminContent = readFileSync(adminDashboardPath, 'utf-8'); const adminContent = readFileSync(adminDashboardPath, 'utf-8');
// Check for material select dropdown // Check for material select dropdown
@@ -54,7 +54,7 @@ describe('UI Features Tests', () => {
}); });
it('should have admin header with navigation', () => { it('should have admin header with navigation', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx'); const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8'); const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');

View File

@@ -161,7 +161,14 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const spulnaNum = parseInt(spulna) || 0; const spulnaNum = parseInt(spulna) || 0;
const kolicina = refillNum + spulnaNum; const kolicina = refillNum + spulnaNum;
const result = await pool.query( // 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 `UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4, SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8, refill = $5, spulna = $6, kolicina = $7, cena = $8,
@@ -172,6 +179,18 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
[tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, [tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id] 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);
@@ -236,6 +255,136 @@ app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => {
} }
}); });
// Color request endpoints
// Get all color requests (admin only)
app.get('/api/color-requests', authenticateToken, async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM color_requests ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
console.error('Error fetching color requests:', error);
res.status(500).json({ error: 'Failed to fetch color requests' });
}
});
// Submit a new color request (public)
app.post('/api/color-requests', async (req, res) => {
try {
const {
color_name,
material_type,
finish_type,
user_email,
user_phone,
user_name,
description,
reference_url
} = req.body;
// Validate required fields
if (!color_name || !material_type || !user_email || !user_phone) {
return res.status(400).json({
error: 'Color name, material type, email, and phone are required'
});
}
// Check if similar request already exists
const existingRequest = await pool.query(
`SELECT id, request_count FROM color_requests
WHERE LOWER(color_name) = LOWER($1)
AND material_type = $2
AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL))
AND status = 'pending'`,
[color_name, material_type, finish_type]
);
if (existingRequest.rows.length > 0) {
// Increment request count for existing request
const result = await pool.query(
`UPDATE color_requests
SET request_count = request_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[existingRequest.rows[0].id]
);
res.json({
message: 'Your request has been added to an existing request for this color',
request: result.rows[0]
});
} else {
// Create new request
const result = await pool.query(
`INSERT INTO color_requests
(color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url]
);
res.json({
message: 'Color request submitted successfully',
request: result.rows[0]
});
}
} catch (error) {
console.error('Error creating color request:', error);
res.status(500).json({ error: 'Failed to submit color request' });
}
});
// Update color request status (admin only)
app.put('/api/color-requests/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const { status, admin_notes } = req.body;
const result = await pool.query(
`UPDATE color_requests
SET status = $1,
admin_notes = $2,
processed_at = CURRENT_TIMESTAMP,
processed_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING *`,
[status, admin_notes, req.user.username, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Color request not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating color request:', error);
res.status(500).json({ error: 'Failed to update color request' });
}
});
// Delete color request (admin only)
app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'DELETE FROM color_requests WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Color request not found' });
}
res.json({ message: 'Color request deleted successfully' });
} catch (error) {
console.error('Error deleting color request:', error);
res.status(500).json({ error: 'Failed to delete color request' });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
}); });

View File

@@ -6,6 +6,7 @@ import { filamentService, colorService } from '@/src/services/api';
import { Filament } from '@/src/types/filament'; import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics'; import { trackEvent } from '@/src/components/MatomoAnalytics';
import { SaleManager } from '@/src/components/SaleManager'; import { SaleManager } from '@/src/components/SaleManager';
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
// Removed unused imports for Bambu Lab color categorization // Removed unused imports for Bambu Lab color categorization
import '@/src/styles/select.css'; import '@/src/styles/select.css';
@@ -32,7 +33,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+' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC'; return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || finish === 'Wood' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
}; };
// Helper function to check if a filament should be refill-only // Helper function to check if a filament should be refill-only
@@ -45,6 +46,21 @@ const isRefillOnly = (color: string, finish?: string, type?: string): boolean =>
if (finish === 'Translucent') { if (finish === 'Translucent') {
return false; return false;
} }
// Specific type/finish/color combinations that are refill-only
if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) {
return true;
}
if (type === 'TPU' && finish === '95A HF') {
return true;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
// Galaxy and Basic colors have spools available (not refill-only)
if (finish === 'Galaxy' || finish === 'Basic') {
return false;
}
return REFILL_ONLY_COLORS.includes(color); return REFILL_ONLY_COLORS.includes(color);
}; };
@@ -67,13 +83,14 @@ interface FilamentWithId extends Filament {
// Finish options by filament type // Finish options by filament type
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = { const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
'ABS': ['GF', 'Bez Finisha'], 'ABS': ['GF', 'Bez Finisha'],
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Translucent', 'Wood'], 'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', 'Tough+', 'Translucent', 'Wood'],
'TPU': ['85A', '90A', '95A HF'], 'TPU': ['85A', '90A', '95A HF'],
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'], 'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
'PC': ['CF', 'FR', 'Bez Finisha'], 'PC': ['CF', 'FR', 'Bez Finisha'],
'ASA': ['Bez Finisha'], 'ASA': ['Bez Finisha'],
'PA': ['CF', 'GF', 'Bez Finisha'], 'PA': ['CF', 'GF', 'Bez Finisha'],
'PA6': ['CF', 'GF'], 'PA6': ['CF', 'GF'],
'PAHT': ['CF', 'Bez Finisha'],
'PPA': ['CF'], 'PPA': ['CF'],
'PVA': ['Bez Finisha'], 'PVA': ['Bez Finisha'],
'HIPS': ['Bez Finisha'] 'HIPS': ['Bez Finisha']
@@ -119,13 +136,16 @@ export default function AdminDashboard() {
// Check authentication // Check authentication
useEffect(() => { useEffect(() => {
// Wait for component to mount to avoid SSR issues with localStorage
if (!mounted) return;
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry'); const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) { if (!token || !expiry || Date.now() > parseInt(expiry)) {
router.push('/upadaj'); window.location.href = '/upadaj';
} }
}, [router]); }, [mounted]);
// Fetch filaments // Fetch filaments
const fetchFilaments = async () => { const fetchFilaments = async () => {
@@ -377,6 +397,22 @@ export default function AdminDashboard() {
selectedFilaments={selectedFilaments} selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments} onSaleUpdate={fetchFilaments}
/> />
<BulkFilamentPriceEditor
filaments={filaments}
onUpdate={fetchFilaments}
/>
<button
onClick={() => router.push('/upadaj/colors')}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm sm:text-base"
>
Boje
</button>
<button
onClick={() => router.push('/upadaj/requests')}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
>
Zahtevi
</button>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base" className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm sm:text-base"
@@ -448,8 +484,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: Finish (A-Z)</option> <option value="finish-asc">Sortiraj po: Finiš (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finish (Z-A)</option> <option value="finish-desc">Sortiraj po: Finiš (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>
@@ -495,16 +531,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">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')} Finiš {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">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')} Refil {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">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')} Špulna {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' ? '↑' : '↓')}
@@ -682,6 +718,9 @@ function FilamentForm({
cena_spulna: 0, cena_spulna: 0,
}); });
// Track if this is the initial load to prevent price override
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Update form when filament prop changes // Update form when filament prop changes
useEffect(() => { useEffect(() => {
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499") // Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
@@ -711,10 +750,19 @@ function FilamentForm({
cena_refill: refillPrice || 3499, cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999, cena_spulna: spulnaPrice || 3999,
}); });
// Reset initial load flag when filament changes
setIsInitialLoad(true);
}, [filament]); }, [filament]);
// Update prices when color selection changes // Update prices when color selection changes (but not on initial load)
useEffect(() => { useEffect(() => {
// Skip price update on initial load to preserve existing filament prices
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
if (formData.boja && availableColors.length > 0) { if (formData.boja && availableColors.length > 0) {
const colorData = availableColors.find(c => c.name === formData.boja); const colorData = availableColors.find(c => c.name === formData.boja);
if (colorData) { if (colorData) {
@@ -854,7 +902,7 @@ function FilamentForm({
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finish</label> <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Finiš</label>
<select <select
name="finish" name="finish"
value={formData.finish} value={formData.finish}
@@ -862,7 +910,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 finish</option> <option value="">Izaberi finiš</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>
))} ))}
@@ -933,7 +981,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 Refill</span> <span className="text-green-600 dark:text-green-400">Cena Refila</span>
</label> </label>
<input <input
type="number" type="number"
@@ -954,7 +1002,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 Spulna</span> <span className="text-blue-500 dark:text-blue-400">Cena Špulne</span>
</label> </label>
<input <input
type="number" type="number"
@@ -976,9 +1024,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">
Refill Refil
{isSpoolOnly(formData.finish, formData.tip) && ( {isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo spulna postoji)</span> <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo špulna postoji)</span>
)} )}
</label> </label>
<input <input
@@ -1000,7 +1048,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">
Spulna Špulna
{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>
)} )}

View File

@@ -3,6 +3,7 @@
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 { 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';
@@ -14,6 +15,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);
// 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
@@ -164,15 +166,28 @@ export default function Home() {
</a> </a>
<a <a
href="tel:+381677102845" href="tel:+381631031048"
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 67 710 2845 Pozovi +381 63 103 1048
</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 <SaleCountdown
@@ -190,6 +205,42 @@ export default function Home() {
})()} })()}
/> />
{/* 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>
</div>
<FilamentTableV2 <FilamentTableV2
key={resetKey} key={resetKey}
filaments={filaments} filaments={filaments}
@@ -198,24 +249,29 @@ export default function Home() {
<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 sm:flex-row justify-center items-center gap-6"> <div className="flex flex-col justify-center items-center gap-6">
<div className="text-center sm:text-left"> <div className="text-center">
<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:+381677102845" href="tel:+381631031048"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors" 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 67 710 2845 +381 63 103 1048
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
{/* Color Request Modal */}
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { colorService } from '@/src/services/api'; import { colorService } from '@/src/services/api';
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete'; import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
import '@/src/styles/select.css'; import '@/src/styles/select.css';
interface Color { interface Color {
@@ -53,13 +54,16 @@ export default function ColorsManagement() {
// Check authentication // Check authentication
useEffect(() => { useEffect(() => {
// Wait for component to mount to avoid SSR issues with localStorage
if (!mounted) return;
const token = localStorage.getItem('authToken'); const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry'); const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) { if (!token || !expiry || Date.now() > parseInt(expiry)) {
router.push('/upadaj'); window.location.href = '/upadaj';
} }
}, [router]); }, [mounted]);
// Fetch colors // Fetch colors
const fetchColors = async () => { const fetchColors = async () => {
@@ -190,7 +194,7 @@ export default function ColorsManagement() {
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2> <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
<nav className="space-y-2"> <nav className="space-y-2">
<a <a
href="/upadaj/dashboard" href="/dashboard"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
> >
Filamenti Filamenti
@@ -218,7 +222,7 @@ export default function ColorsManagement() {
/> />
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">Upravljanje bojama</h1>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4 flex-wrap">
{!showAddForm && !editingColor && ( {!showAddForm && !editingColor && (
<button <button
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
@@ -227,6 +231,7 @@ export default function ColorsManagement() {
Dodaj novu boju Dodaj novu boju
</button> </button>
)} )}
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
{selectedColors.size > 0 && ( {selectedColors.size > 0 && (
<button <button
onClick={handleBulkDelete} onClick={handleBulkDelete}

View File

@@ -32,8 +32,8 @@ export default function AdminLogin() {
// Track successful login // Track successful login
trackEvent('Admin', 'Login', 'Success'); trackEvent('Admin', 'Login', 'Success');
// Redirect to admin dashboard // Redirect to admin dashboard using window.location for static export
router.push('/upadaj/dashboard'); window.location.href = '/dashboard';
} catch (err: any) { } catch (err: any) {
setError('Neispravno korisničko ime ili lozinka'); setError('Neispravno korisničko ime ili lozinka');
console.error('Login error:', err); console.error('Login error:', err);

View File

@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { colorRequestService } from '@/src/services/api';
import Link from 'next/link';
interface ColorRequest {
id: string;
color_name: string;
material_type: string;
finish_type: string;
user_email: string;
user_phone: string;
user_name: string;
description: string;
reference_url: string;
status: 'pending' | 'approved' | 'rejected' | 'completed';
admin_notes: string;
request_count: number;
created_at: string;
updated_at: string;
processed_at: string;
processed_by: string;
}
export default function ColorRequestsAdmin() {
const [requests, setRequests] = useState<ColorRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ status: '', admin_notes: '' });
const router = useRouter();
useEffect(() => {
checkAuth();
fetchRequests();
}, []);
const checkAuth = () => {
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
window.location.href = '/upadaj';
}
};
const fetchRequests = async () => {
try {
const data = await colorRequestService.getAll();
setRequests(data);
} catch (error) {
setError('Failed to fetch color requests');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleStatusUpdate = async (id: string) => {
try {
await colorRequestService.updateStatus(id, editForm.status, editForm.admin_notes);
await fetchRequests();
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
} catch (error) {
setError('Failed to update request');
console.error('Error:', error);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this request?')) return;
try {
await colorRequestService.delete(id);
await fetchRequests();
} catch (error) {
setError('Failed to delete request');
console.error('Error:', error);
}
};
const getStatusBadge = (status: string) => {
const colors = {
pending: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
approved: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
};
const getStatusLabel = (status: string) => {
const labels = {
pending: 'Na čekanju',
approved: 'Odobreno',
rejected: 'Odbijeno',
completed: 'Završeno'
};
return labels[status as keyof typeof labels] || status;
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
const date = new Date(dateString);
const month = date.toLocaleDateString('sr-RS', { month: 'short' });
const capitalizedMonth = month.charAt(0).toUpperCase() + month.slice(1);
return `${capitalizedMonth} ${date.getDate()}, ${date.getFullYear()}`;
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
<div className="space-x-4">
<Link
href="/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar
</Link>
<Link
href="/upadaj/colors"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Boje
</Link>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Materijal/Finiš
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{request.color_name}</div>
{request.description && (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
href={request.reference_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Pogledaj referencu
</a>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
{request.finish_type && (
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
{request.request_count || 1} {(request.request_count || 1) === 1 ? 'zahtev' : 'zahteva'}
</span>
</td>
<td className="px-4 py-3">
<div className="text-sm">
{request.user_email ? (
<a href={`mailto:${request.user_email}`} className="text-blue-600 dark:text-blue-400 hover:underline">
{request.user_email}
</a>
) : (
<span className="text-gray-400 dark:text-gray-500">Anonimno</span>
)}
{request.user_phone && (
<div className="mt-1">
<a href={`tel:${request.user_phone}`} className="text-blue-600 dark:text-blue-400 hover:underline">
{request.user_phone}
</a>
</div>
)}
</div>
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-y-2">
<select
value={editForm.status}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
className="text-sm border rounded px-2 py-1"
>
<option value="">Izaberi status</option>
<option value="pending">Na čekanju</option>
<option value="approved">Odobreno</option>
<option value="rejected">Odbijeno</option>
<option value="completed">Završeno</option>
</select>
<textarea
placeholder="Napomene..."
value={editForm.admin_notes}
onChange={(e) => setEditForm({ ...editForm, admin_notes: e.target.value })}
className="text-sm border rounded px-2 py-1 w-full"
rows={2}
/>
</div>
) : (
<div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(request.status)}`}>
{getStatusLabel(request.status)}
</span>
{request.admin_notes && (
<div className="text-xs text-gray-500 mt-1">{request.admin_notes}</div>
)}
{request.processed_by && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
od {request.processed_by}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Obrađeno: {formatDate(request.processed_at)}
</div>
)}
</td>
<td className="px-4 py-3">
{editingId === request.id ? (
<div className="space-x-2">
<button
onClick={() => handleStatusUpdate(request.id)}
className="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 text-sm"
>
Sačuvaj
</button>
<button
onClick={() => {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
>
Otkaži
</button>
</div>
) : (
<div className="space-x-2">
<button
onClick={() => {
setEditingId(request.id);
setEditForm({
status: request.status,
admin_notes: request.admin_notes || ''
});
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm"
>
Izmeni
</button>
<button
onClick={() => handleDelete(request.id)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm"
>
Obriši
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Nema zahteva za boje
</div>
)}
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{requests.length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
-- Migration: Add color requests feature
-- Allows users to request new colors and admins to view/manage requests
-- Create color_requests table
CREATE TABLE IF NOT EXISTS color_requests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
color_name VARCHAR(100) NOT NULL,
material_type VARCHAR(50) NOT NULL,
finish_type VARCHAR(50),
user_email VARCHAR(255),
user_name VARCHAR(100),
description TEXT,
reference_url VARCHAR(500),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
admin_notes TEXT,
request_count INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP WITH TIME ZONE,
processed_by VARCHAR(100)
);
-- Create indexes for better performance
CREATE INDEX idx_color_requests_status ON color_requests(status);
CREATE INDEX idx_color_requests_created_at ON color_requests(created_at DESC);
CREATE INDEX idx_color_requests_color_name ON color_requests(LOWER(color_name));
-- Apply updated_at trigger to color_requests table
CREATE TRIGGER update_color_requests_updated_at BEFORE UPDATE
ON color_requests FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Add comment to describe the table
COMMENT ON TABLE color_requests IS 'User requests for new filament colors to be added to inventory';
COMMENT ON COLUMN color_requests.status IS 'Request status: pending (new), approved (will be ordered), rejected (won''t be added), completed (added to inventory)';
COMMENT ON COLUMN color_requests.request_count IS 'Number of users who have requested this same color';

View File

@@ -0,0 +1,8 @@
-- Migration: Add phone field to color_requests table
-- Allows users to provide phone number for contact
ALTER TABLE color_requests
ADD COLUMN IF NOT EXISTS user_phone VARCHAR(50);
-- Add comment to describe the new column
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (optional)';

View File

@@ -0,0 +1,22 @@
-- Migration: Make email and phone fields required in color_requests table
-- These fields are now mandatory for all color requests
-- First, update any existing NULL values to prevent constraint violation
UPDATE color_requests
SET user_email = 'unknown@example.com'
WHERE user_email IS NULL;
UPDATE color_requests
SET user_phone = 'unknown'
WHERE user_phone IS NULL;
-- Now add NOT NULL constraints
ALTER TABLE color_requests
ALTER COLUMN user_email SET NOT NULL;
ALTER TABLE color_requests
ALTER COLUMN user_phone SET NOT NULL;
-- Update comments to reflect the requirement
COMMENT ON COLUMN color_requests.user_email IS 'User email address for contact (required)';
COMMENT ON COLUMN color_requests.user_phone IS 'User phone number for contact (required)';

View File

@@ -0,0 +1,77 @@
-- 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

@@ -0,0 +1,102 @@
-- Migration: Add missing Bambu Lab colors (2025)
-- Created: 2025-11-13
-- Description: Adds missing color definitions from Bambu Lab catalog
INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES
-- Basic/Matte Colors
('Clear Black', '#1a1a1a', 3499, 3999),
('Transparent', '#f0f0f0', 3499, 3999),
('Brick Red', '#8b2e2e', 3499, 3999),
('Titan Gray', '#5c6670', 3499, 3999),
('Indigo Blue', '#4b0082', 3499, 3999),
('Malachite Green', '#0bda51', 3499, 3999),
('Violet Purple', '#8f00ff', 3499, 3999),
('Iris Purple', '#5d3fd3', 3499, 3999),
('Royal Blue', '#4169e1', 3499, 3999),
('Lava Gray', '#4a4a4a', 3499, 3999),
('Burgundy Red', '#800020', 3499, 3999),
('Matcha Green', '#8db255', 3499, 3999),
('Light Cyan', '#e0ffff', 3499, 3999),
-- Glow Colors
('Blaze', '#ff6347', 3499, 3999),
('Glow Blue', '#00d4ff', 3499, 3999),
('Glow Pink', '#ff69b4', 3499, 3999),
('Glow Yellow', '#ffff00', 3499, 3999),
('Glow Orange', '#ff8c00', 3499, 3999),
('Nebulane', '#9b59b6', 3499, 3999),
-- Metallic Colors
('IronGray Metallic', '#71797e', 3499, 3999),
('Copper Brown Metallic', '#b87333', 3499, 3999),
('Iridium Gold Metallic', '#d4af37', 3499, 3999),
('Oxide Green Metallic', '#6b8e23', 3499, 3999),
('Cobalt Blue Metallic', '#0047ab', 3499, 3999),
-- Marble Colors
('White Marble', '#f5f5f5', 3499, 3999),
('Red Granite', '#8b4513', 3499, 3999),
-- Sparkle Colors
('Classic Gold Sparkle', '#ffd700', 3499, 3999),
('Onyx Black Sparkle', '#0f0f0f', 3499, 3999),
('Crimson Red Sparkle', '#dc143c', 3499, 3999),
('Royal Purple Sparkle', '#7851a9', 3499, 3999),
('Slate Gray Sparkle', '#708090', 3499, 3999),
('Alpine Green Sparkle', '#2e8b57', 3499, 3999),
-- Gradient Colors
('South Beach', '#ff69b4', 3499, 3999),
('Dawn Radiance', '#ff7f50', 3499, 3999),
('Blue Hawaii', '#1e90ff', 3499, 3999),
('Gilded Rose', '#ff1493', 3499, 3999),
('Midnight Blaze', '#191970', 3499, 3999),
('Neon City', '#00ffff', 3499, 3999),
('Velvet Eclipse', '#8b008b', 3499, 3999),
('Solar Breeze', '#ffb347', 3499, 3999),
('Arctic Whisper', '#b0e0e6', 3499, 3999),
('Ocean to Meadow', '#40e0d0', 3499, 3999),
('Dusk Glare', '#ff6347', 3499, 3999),
('Mint Lime', '#98fb98', 3499, 3999),
('Pink Citrus', '#ff69b4', 3499, 3999),
('Blueberry Bubblegum', '#7b68ee', 3499, 3999),
('Cotton Candy Cloud', '#ffb6c1', 3499, 3999),
-- Pastel/Light Colors
('Lavender', '#e6e6fa', 3499, 3999),
('Ice Blue', '#d0e7ff', 3499, 3999),
('Mellow Yellow', '#fff44f', 3499, 3999),
('Teal', '#008080', 3499, 3999),
-- ABS Colors
('ABS Azure', '#007fff', 3499, 3999),
('ABS Olive', '#808000', 3499, 3999),
('ABS Blue', '#0000ff', 3499, 3999),
('ABS Tangerine Yellow', '#ffcc00', 3499, 3999),
('ABS Navy Blue', '#000080', 3499, 3999),
('ABS Orange', '#ff8800', 3499, 3999),
('ABS Bambu Green', '#00c853', 3499, 3999),
('ABS Red', '#ff0000', 3499, 3999),
('ABS White', '#ffffff', 3499, 3999),
('ABS Black', '#000000', 3499, 3999),
('ABS Silver', '#c0c0c0', 3499, 3999),
-- Translucent Colors
('Translucent Gray', '#9e9e9e', 3499, 3999),
('Translucent Brown', '#8b4513', 3499, 3999),
('Translucent Purple', '#9370db', 3499, 3999),
('Translucent Orange', '#ffa500', 3499, 3999),
('Translucent Olive', '#6b8e23', 3499, 3999),
('Translucent Pink', '#ffb6c1', 3499, 3999),
('Translucent Light Blue', '#add8e6', 3499, 3999),
('Translucent Tea', '#d2b48c', 3499, 3999),
-- Additional Colors
('Mint', '#98ff98', 3499, 3999),
('Champagne', '#f7e7ce', 3499, 3999),
('Baby Blue', '#89cff0', 3499, 3999),
('Cream', '#fffdd0', 3499, 3999),
('Peanut Brown', '#b86f52', 3499, 3999),
('Matte Ivory', '#fffff0', 3499, 3999)
ON CONFLICT (name) DO NOTHING;

3516
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "./scripts/kill-dev.sh && next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",

View File

@@ -0,0 +1,92 @@
const { Pool } = require('pg');
const connectionString = "postgresql://filamenteka_admin:onrBjiAjHKQXBAJSVWU2t2kQ7HDil9re@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka";
const pool = new Pool({
connectionString,
ssl: { rejectUnauthorized: false }
});
const missingColors = [
// PLA Matte - New colors with correct hex codes
{ name: "Matte Dark Chocolate", hex: "#4A3729", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Dark Brown", hex: "#7D6556", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Terracotta", hex: "#A25A37", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Caramel", hex: "#A4845C", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Dark Blue", hex: "#042F56", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Sky Blue", hex: "#73B2E5", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Ice Blue", hex: "#A3D8E1", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Dark Green", hex: "#68724D", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Grass Green", hex: "#61C680", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Apple Green", hex: "#C6E188", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Dark Red", hex: "#BB3D43", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Plum", hex: "#851A52", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Lilac Purple", hex: "#AE96D4", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Sakura Pink", hex: "#E8AFCF", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Lemon Yellow", hex: "#F7D959", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Matte Bone White", hex: "#C8C5B6", cena_refill: 3499, cena_spulna: 3999 },
// PLA Wood colors
{ name: "Ochre Yellow", hex: "#BC8B39", cena_refill: 3499, cena_spulna: 3999 },
{ name: "White Oak", hex: "#D2CCA2", cena_refill: 3499, cena_spulna: 3999 },
{ name: "Clay Brown", hex: "#8E621A", cena_refill: 3499, cena_spulna: 3999 }
];
async function addMissingColors() {
try {
console.log('🎨 Adding missing colors to database...\n');
let added = 0;
let updated = 0;
let skipped = 0;
for (const color of missingColors) {
try {
// Check if color already exists
const existingColor = await pool.query(
'SELECT * FROM colors WHERE name = $1',
[color.name]
);
if (existingColor.rows.length > 0) {
// Update existing color with correct hex code
const existing = existingColor.rows[0];
if (existing.hex !== color.hex) {
await pool.query(
'UPDATE colors SET hex = $1, cena_refill = $2, cena_spulna = $3 WHERE name = $4',
[color.hex, color.cena_refill, color.cena_spulna, color.name]
);
console.log(`✅ Updated: ${color.name} (${existing.hex}${color.hex})`);
updated++;
} else {
console.log(`⏭️ Skipped: ${color.name} (already exists with correct hex)`);
skipped++;
}
} else {
// Insert new color
await pool.query(
'INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES ($1, $2, $3, $4)',
[color.name, color.hex, color.cena_refill, color.cena_spulna]
);
console.log(`✅ Added: ${color.name} (${color.hex})`);
added++;
}
} catch (error) {
console.error(`❌ Error with ${color.name}:`, error.message);
}
}
console.log(`\n📊 Summary:`);
console.log(` Added: ${added}`);
console.log(` Updated: ${updated}`);
console.log(` Skipped: ${skipped}`);
console.log(` Total: ${missingColors.length}`);
} catch (error) {
console.error('❌ Failed to add colors:', error.message);
} finally {
await pool.end();
}
}
addMissingColors();

97
scripts/deploy-frontend.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
# Frontend deployment script for CloudFront + S3
# This script builds the Next.js app and deploys it to S3 + CloudFront
set -e
echo "🚀 Starting frontend deployment..."
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Configuration
S3_BUCKET="filamenteka-frontend"
REGION="eu-central-1"
# Get CloudFront distribution ID from terraform output
echo -e "${BLUE}📋 Getting CloudFront distribution ID...${NC}"
DISTRIBUTION_ID=$(terraform -chdir=terraform output -raw cloudfront_distribution_id 2>/dev/null || terraform output -raw cloudfront_distribution_id)
if [ -z "$DISTRIBUTION_ID" ]; then
echo -e "${RED}❌ Could not get CloudFront distribution ID${NC}"
exit 1
fi
echo -e "${GREEN}✓ Distribution ID: $DISTRIBUTION_ID${NC}"
# Build the Next.js app
echo -e "${BLUE}🔨 Building Next.js app...${NC}"
npm run build
if [ ! -d "out" ]; then
echo -e "${RED}❌ Build failed - 'out' directory not found${NC}"
exit 1
fi
echo -e "${GREEN}✓ Build completed${NC}"
# Upload to S3 with proper cache headers
echo -e "${BLUE}📤 Uploading to S3 with optimized cache headers...${NC}"
# First, delete old files
echo -e "${BLUE} 🗑️ Cleaning old files...${NC}"
aws s3 sync out/ s3://$S3_BUCKET/ \
--region $REGION \
--delete \
--exclude "*"
# Upload HTML files with no-cache (always revalidate)
echo -e "${BLUE} 📄 Uploading HTML files (no-cache)...${NC}"
aws s3 sync out/ s3://$S3_BUCKET/ \
--region $REGION \
--exclude "*" \
--include "*.html" \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html"
# Upload _next static assets with long-term cache (immutable, 1 year)
echo -e "${BLUE} 🎨 Uploading Next.js static assets (1 year cache)...${NC}"
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
--region $REGION \
--cache-control "public, max-age=31536000, immutable"
# Upload other static assets with moderate cache (1 day)
echo -e "${BLUE} 📦 Uploading other assets (1 day cache)...${NC}"
aws s3 sync out/ s3://$S3_BUCKET/ \
--region $REGION \
--exclude "*" \
--exclude "*.html" \
--exclude "_next/*" \
--include "*" \
--cache-control "public, max-age=86400"
echo -e "${GREEN}✓ All files uploaded to S3 with optimized cache headers${NC}"
# Invalidate CloudFront cache
echo -e "${BLUE}🔄 Invalidating CloudFront cache...${NC}"
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id $DISTRIBUTION_ID \
--paths "/*" \
--query 'Invalidation.Id' \
--output text)
echo -e "${GREEN}✓ CloudFront invalidation created: $INVALIDATION_ID${NC}"
# Get CloudFront URL
CF_URL=$(terraform -chdir=terraform output -raw cloudfront_domain_name 2>/dev/null || terraform output -raw cloudfront_domain_name)
echo ""
echo -e "${GREEN}✅ Deployment complete!${NC}"
echo -e "${BLUE}🌐 CloudFront URL: https://$CF_URL${NC}"
echo -e "${BLUE}🌐 Custom Domain: https://filamenteka.rs (after DNS update)${NC}"
echo ""
echo -e "${BLUE} Note: CloudFront cache invalidation may take 5-10 minutes to propagate globally.${NC}"

26
scripts/kill-dev.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Kill any processes running on ports 3000 and 3001
echo "🔍 Checking for processes on ports 3000 and 3001..."
PIDS=$(lsof -ti:3000,3001 2>/dev/null)
if [ -n "$PIDS" ]; then
echo "$PIDS" | xargs kill -9 2>/dev/null
echo "✅ Killed processes on ports 3000/3001"
else
echo " No processes found on ports 3000/3001"
fi
# Kill any old Next.js dev server processes (but not the current script)
echo "🔍 Checking for Next.js dev processes..."
OLD_PIDS=$(ps aux | grep -i "next dev" | grep -v grep | grep -v "kill-dev.sh" | awk '{print $2}')
if [ -n "$OLD_PIDS" ]; then
echo "$OLD_PIDS" | xargs kill -9 2>/dev/null
echo "✅ Killed Next.js dev processes"
else
echo " No Next.js dev processes found"
fi
# Give it a moment to clean up
sleep 0.5
echo "✨ Ready to start fresh dev server!"

View File

@@ -0,0 +1,77 @@
// 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,363 @@
import React, { useState, useMemo } from 'react';
import { filamentService, colorService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
interface BulkFilamentPriceEditorProps {
filaments: Array<Filament & { id: string }>;
onUpdate: () => void;
}
export function BulkFilamentPriceEditor({ filaments, onUpdate }: BulkFilamentPriceEditorProps) {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
// Filters
const [selectedMaterial, setSelectedMaterial] = useState<string>('');
const [selectedFinish, setSelectedFinish] = useState<string>('');
const [searchTerm, setSearchTerm] = useState('');
// Price inputs
const [newRefillPrice, setNewRefillPrice] = useState<string>('');
const [newSpoolPrice, setNewSpoolPrice] = useState<string>('');
// Get unique materials and finishes
const materials = useMemo(() =>
[...new Set(filaments.map(f => f.tip))].sort(),
[filaments]
);
const finishes = useMemo(() =>
[...new Set(filaments.map(f => f.finish))].sort(),
[filaments]
);
// Filter filaments based on selections
const filteredFilaments = useMemo(() => {
return filaments.filter(f => {
const matchesMaterial = !selectedMaterial || f.tip === selectedMaterial;
const matchesFinish = !selectedFinish || f.finish === selectedFinish;
const matchesSearch = !searchTerm ||
f.boja.toLowerCase().includes(searchTerm.toLowerCase()) ||
f.tip.toLowerCase().includes(searchTerm.toLowerCase()) ||
f.finish.toLowerCase().includes(searchTerm.toLowerCase());
return matchesMaterial && matchesFinish && matchesSearch;
});
}, [filaments, selectedMaterial, selectedFinish, searchTerm]);
// Group filaments by color (since multiple filaments can have same color)
const colorGroups = useMemo(() => {
const groups = new Map<string, Array<Filament & { id: string }>>();
filteredFilaments.forEach(f => {
const existing = groups.get(f.boja) || [];
existing.push(f);
groups.set(f.boja, existing);
});
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [filteredFilaments]);
const handleApplyPrices = async () => {
const refillPrice = parseInt(newRefillPrice);
const spoolPrice = parseInt(newSpoolPrice);
if (isNaN(refillPrice) && isNaN(spoolPrice)) {
alert('Molimo unesite bar jednu cenu (refill ili spulna)');
return;
}
if ((refillPrice && refillPrice < 0) || (spoolPrice && spoolPrice < 0)) {
alert('Cena ne može biti negativna');
return;
}
if (filteredFilaments.length === 0) {
alert('Nema filamenata koji odgovaraju filterima');
return;
}
const confirmMsg = `Želite da promenite cene za ${filteredFilaments.length} filament(a)?
${selectedMaterial ? `\nMaterijal: ${selectedMaterial}` : ''}
${selectedFinish ? `\nFiniš: ${selectedFinish}` : ''}
${!isNaN(refillPrice) ? `\nRefill cena: ${refillPrice}` : ''}
${!isNaN(spoolPrice) ? `\nSpulna cena: ${spoolPrice}` : ''}`;
if (!confirm(confirmMsg)) {
return;
}
setLoading(true);
try {
// Update each filament
await Promise.all(
filteredFilaments.map(async (filament) => {
// Parse current prices
const prices = filament.cena.split('/');
const currentRefillPrice = parseInt(prices[0]) || 3499;
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
// Use new prices if provided, otherwise keep current
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
// Build price string based on quantities
let priceString = '';
if (filament.refill > 0 && filament.spulna > 0) {
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
} else if (filament.refill > 0) {
priceString = String(finalRefillPrice);
} else if (filament.spulna > 0) {
priceString = String(finalSpoolPrice);
} else {
priceString = `${finalRefillPrice}/${finalSpoolPrice}`;
}
return filamentService.update(filament.id, {
tip: filament.tip,
finish: filament.finish,
boja: filament.boja,
boja_hex: filament.boja_hex,
refill: filament.refill,
spulna: filament.spulna,
cena: priceString
});
})
);
alert(`Uspešno ažurirano ${filteredFilaments.length} filament(a)!`);
setNewRefillPrice('');
setNewSpoolPrice('');
onUpdate();
setShowModal(false);
} catch (error) {
console.error('Error updating prices:', error);
alert('Greška pri ažuriranju cena');
} finally {
setLoading(false);
}
};
const resetFilters = () => {
setSelectedMaterial('');
setSelectedFinish('');
setSearchTerm('');
setNewRefillPrice('');
setNewSpoolPrice('');
};
return (
<>
<button
onClick={() => setShowModal(true)}
className="flex-1 sm:flex-initial px-3 sm:px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600 text-sm sm:text-base"
>
Masovno editovanje cena
</button>
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Masovno editovanje cena
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl"
>
</button>
</div>
{/* Filters */}
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded space-y-3">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">Filteri:</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Materijal
</label>
<select
value={selectedMaterial}
onChange={(e) => setSelectedMaterial(e.target.value)}
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
>
<option value="">Svi materijali</option>
{materials.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Finiš
</label>
<select
value={selectedFinish}
onChange={(e) => setSelectedFinish(e.target.value)}
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
>
<option value="">Svi finiševi</option>
{finishes.map(f => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Pretraga
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Pretraži boju, tip, finiš..."
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Prikazano: {filteredFilaments.length} filament(a)
</p>
</div>
{/* Price inputs */}
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">
Nove cene za filtrirane filamente:
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Refill cena
</label>
<input
type="number"
value={newRefillPrice}
onChange={(e) => setNewRefillPrice(e.target.value)}
placeholder="Npr. 3499"
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Spulna cena
</label>
<input
type="number"
value={newSpoolPrice}
onChange={(e) => setNewSpoolPrice(e.target.value)}
placeholder="Npr. 3999"
className="w-full px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
Napomena: Možete promeniti samo refill, samo spulna, ili obe cene. Prazna polja će zadržati postojeće cene.
</p>
</div>
{/* Preview */}
<div className="flex-1 overflow-y-auto mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
Pregled filamenata ({colorGroups.length} boja):
</h3>
{colorGroups.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
Nema filamenata koji odgovaraju filterima
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Boja</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Tip</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Finiš</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Refill</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Spulna</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Trenutna cena</th>
<th className="px-3 py-2 text-left text-gray-900 dark:text-white">Nova cena</th>
</tr>
</thead>
<tbody>
{colorGroups.map(([color, filamentGroup]) =>
filamentGroup.map((f, idx) => {
const prices = f.cena.split('/');
const currentRefillPrice = parseInt(prices[0]) || 3499;
const currentSpoolPrice = prices.length > 1 ? parseInt(prices[1]) || 3999 : 3999;
const refillPrice = parseInt(newRefillPrice);
const spoolPrice = parseInt(newSpoolPrice);
const finalRefillPrice = !isNaN(refillPrice) ? refillPrice : currentRefillPrice;
const finalSpoolPrice = !isNaN(spoolPrice) ? spoolPrice : currentSpoolPrice;
let newPriceString = '';
if (f.refill > 0 && f.spulna > 0) {
newPriceString = `${finalRefillPrice}/${finalSpoolPrice}`;
} else if (f.refill > 0) {
newPriceString = String(finalRefillPrice);
} else if (f.spulna > 0) {
newPriceString = String(finalSpoolPrice);
}
const priceChanged = newPriceString !== f.cena;
return (
<tr
key={`${f.id}-${idx}`}
className={`border-b dark:border-gray-700 ${priceChanged ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
>
<td className="px-3 py-2 text-gray-900 dark:text-white">{f.boja}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.tip}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.finish}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.refill}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.spulna}</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{f.cena}</td>
<td className={`px-3 py-2 font-semibold ${priceChanged ? 'text-green-600 dark:text-green-400' : 'text-gray-700 dark:text-gray-300'}`}>
{newPriceString || f.cena}
</td>
</tr>
);
})
)}
</tbody>
</table>
)}
</div>
{/* Actions */}
<div className="flex justify-between gap-4 pt-4 border-t dark:border-gray-700">
<button
onClick={resetFilters}
disabled={loading}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
>
Resetuj filtere
</button>
<div className="flex gap-2">
<button
onClick={() => setShowModal(false)}
disabled={loading}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
>
Zatvori
</button>
<button
onClick={handleApplyPrices}
disabled={loading || filteredFilaments.length === 0}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
>
{loading ? 'Čuvanje...' : `Primeni cene (${filteredFilaments.length})`}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { colorService } from '@/src/services/api';
interface Color {
id: string;
name: string;
hex: string;
cena_refill?: number;
cena_spulna?: number;
}
interface BulkPriceEditorProps {
colors: Color[];
onUpdate: () => void;
}
export function BulkPriceEditor({ colors, onUpdate }: BulkPriceEditorProps) {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [priceChanges, setPriceChanges] = useState<Record<string, { cena_refill?: number; cena_spulna?: number }>>({});
const [globalRefillPrice, setGlobalRefillPrice] = useState<string>('');
const [globalSpulnaPrice, setGlobalSpulnaPrice] = useState<string>('');
// Filter colors based on search
const filteredColors = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Apply global price to all filtered colors
const applyGlobalRefillPrice = () => {
if (!globalRefillPrice) return;
const price = parseInt(globalRefillPrice);
if (isNaN(price) || price < 0) return;
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
filteredColors.forEach(color => {
updates[color.id] = {
...updates[color.id],
cena_refill: price
};
});
setPriceChanges(updates);
};
const applyGlobalSpulnaPrice = () => {
if (!globalSpulnaPrice) return;
const price = parseInt(globalSpulnaPrice);
if (isNaN(price) || price < 0) return;
const updates: Record<string, { cena_refill?: number; cena_spulna?: number }> = { ...priceChanges };
filteredColors.forEach(color => {
updates[color.id] = {
...updates[color.id],
cena_spulna: price
};
});
setPriceChanges(updates);
};
// Update individual color price
const updatePrice = (colorId: string, field: 'cena_refill' | 'cena_spulna', value: string) => {
const price = parseInt(value);
if (value === '' || (price >= 0 && !isNaN(price))) {
setPriceChanges(prev => ({
...prev,
[colorId]: {
...prev[colorId],
[field]: value === '' ? undefined : price
}
}));
}
};
// Get effective price (with changes or original)
const getEffectivePrice = (color: Color, field: 'cena_refill' | 'cena_spulna'): number => {
return priceChanges[color.id]?.[field] ?? color[field] ?? (field === 'cena_refill' ? 3499 : 3999);
};
// Save all changes
const handleSave = async () => {
const colorUpdates = Object.entries(priceChanges).filter(([_, changes]) =>
changes.cena_refill !== undefined || changes.cena_spulna !== undefined
);
if (colorUpdates.length === 0) {
alert('Nema promena za čuvanje');
return;
}
if (!confirm(`Želite da sačuvate promene za ${colorUpdates.length} boja?`)) {
return;
}
setLoading(true);
try {
// Update each color individually
await Promise.all(
colorUpdates.map(([colorId, changes]) => {
const color = colors.find(c => c.id === colorId);
if (!color) return Promise.resolve();
return colorService.update(colorId, {
name: color.name,
hex: color.hex,
cena_refill: changes.cena_refill ?? color.cena_refill,
cena_spulna: changes.cena_spulna ?? color.cena_spulna
});
})
);
alert(`Uspešno ažurirano ${colorUpdates.length} boja!`);
setPriceChanges({});
setGlobalRefillPrice('');
setGlobalSpulnaPrice('');
setSearchTerm('');
onUpdate();
setShowModal(false);
} catch (error) {
console.error('Error updating prices:', error);
alert('Greška pri ažuriranju cena');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-indigo-500 text-white rounded hover:bg-indigo-600"
>
Masovno editovanje cena
</button>
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Masovno editovanje cena
</h2>
<button
onClick={() => setShowModal(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
</button>
</div>
{/* Global price controls */}
<div className="mb-4 p-4 bg-gray-100 dark:bg-gray-700 rounded">
<h3 className="font-semibold mb-2 text-gray-900 dark:text-white">Primeni na sve prikazane boje:</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex gap-2">
<input
type="number"
value={globalRefillPrice}
onChange={(e) => setGlobalRefillPrice(e.target.value)}
placeholder="Refill cena"
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
<button
onClick={applyGlobalRefillPrice}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Primeni refill
</button>
</div>
<div className="flex gap-2">
<input
type="number"
value={globalSpulnaPrice}
onChange={(e) => setGlobalSpulnaPrice(e.target.value)}
placeholder="Spulna cena"
className="flex-1 px-3 py-2 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
<button
onClick={applyGlobalSpulnaPrice}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Primeni spulna
</button>
</div>
</div>
</div>
{/* Search */}
<div className="mb-4">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Pretraži boje..."
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Prikazano: {filteredColors.length} od {colors.length} boja
{Object.keys(priceChanges).length > 0 && ` · ${Object.keys(priceChanges).length} promena`}
</p>
</div>
{/* Color list */}
<div className="flex-1 overflow-y-auto mb-4">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Boja</th>
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Refill cena</th>
<th className="px-4 py-2 text-left text-gray-900 dark:text-white">Spulna cena</th>
</tr>
</thead>
<tbody>
{filteredColors.map(color => {
const hasChanges = priceChanges[color.id] !== undefined;
return (
<tr
key={color.id}
className={`border-b dark:border-gray-700 ${hasChanges ? 'bg-yellow-50 dark:bg-yellow-900/20' : ''}`}
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded border border-gray-300"
style={{ backgroundColor: color.hex }}
/>
<span className="text-gray-900 dark:text-white">{color.name}</span>
</div>
</td>
<td className="px-4 py-2">
<input
type="number"
value={getEffectivePrice(color, 'cena_refill')}
onChange={(e) => updatePrice(color.id, 'cena_refill', e.target.value)}
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
</td>
<td className="px-4 py-2">
<input
type="number"
value={getEffectivePrice(color, 'cena_spulna')}
onChange={(e) => updatePrice(color.id, 'cena_spulna', e.target.value)}
className="w-full px-2 py-1 border rounded dark:bg-gray-600 dark:border-gray-500 dark:text-white"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Actions */}
<div className="flex justify-between gap-4">
<button
onClick={() => {
setPriceChanges({});
setGlobalRefillPrice('');
setGlobalSpulnaPrice('');
}}
disabled={loading || Object.keys(priceChanges).length === 0}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 disabled:opacity-50"
>
Poništi promene
</button>
<div className="flex gap-2">
<button
onClick={() => setShowModal(false)}
disabled={loading}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
>
Zatvori
</button>
<button
onClick={handleSave}
disabled={loading || Object.keys(priceChanges).length === 0}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
>
{loading ? 'Čuvanje...' : `Sačuvaj promene (${Object.keys(priceChanges).length})`}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import React, { useState } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestFormProps {
onSuccess?: () => void;
}
export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
const response = await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
if (onSuccess) onSuccess();
} 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="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-800 dark:text-gray-100">Zatraži Novu Boju</h2>
{message && (
<div className={`mb-4 p-4 rounded ${
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 className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_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-blue-500 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Materijala *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
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-blue-500 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
</select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Finiša
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
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-blue-500 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
</select>
</div>
<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-blue-500 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<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-blue-500 appearance-none"
placeholder="063 123 4567"
/>
</div>
</div>
<div className="flex justify-end">
<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 Zahtev'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,232 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ColorRequestModal({ isOpen, onClose }: ColorRequestModalProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset form when modal closes
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
setMessage(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
// Close modal after 2 seconds on success
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>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
{/* Close button */}
<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 Novu Boju
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ne možete pronaći boju koju tražite? Javite nam!
</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="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_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 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Materijal *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
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 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
<option value="Other">Ostalo</option>
</select>
</div>
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Finiš
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
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 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
<option value="Other">Ostalo</option>
</select>
</div>
</div>
<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 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</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 appearance-none"
placeholder="Za kontakt"
/>
</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

@@ -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">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')} Finiš {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">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')} Refil {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">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')} Špulna {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

@@ -33,6 +33,8 @@ 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' },
'Classic Gold Sparkle': { hex: '#E4BD68' },
'Clay Brown': { hex: '#8E621A' }, 'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' }, 'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' }, 'Clear Black': { hex: '#5A5161' },
@@ -70,6 +72,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Indigo Purple': { hex: '#482A60' }, 'Indigo Purple': { hex: '#482A60' },
'Iridium Gold Metallic': { hex: '#B39B84' }, 'Iridium Gold Metallic': { hex: '#B39B84' },
'Iris Purple': { hex: '#69398E' }, 'Iris Purple': { hex: '#69398E' },
'IronGray Metallic': { hex: '#6B6C6F' },
'Ivory White': { hex: '#FFFFFF' }, 'Ivory White': { hex: '#FFFFFF' },
'Jade White': { hex: '#FFFFFF' }, 'Jade White': { hex: '#FFFFFF' },
'Jeans Blue': { hex: '#6E88BC' }, 'Jeans Blue': { hex: '#6E88BC' },
@@ -99,6 +102,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Nardo Gray': { hex: '#747474' }, 'Nardo Gray': { hex: '#747474' },
'Navy Blue': { hex: '#0C2340' }, 'Navy Blue': { hex: '#0C2340' },
'Nebulae': { hex: '#424379' }, 'Nebulae': { hex: '#424379' },
'Nebulane': { hex: '#424379' },
'Neon City': { hex: '#0047BB' }, 'Neon City': { hex: '#0047BB' },
'Neon Green': { hex: '#ABFF1E' }, 'Neon Green': { hex: '#ABFF1E' },
'Neon Orange': { hex: '#F68A1B' }, 'Neon Orange': { hex: '#F68A1B' },
@@ -141,6 +145,48 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Oak': { hex: '#D2CCA2' }, 'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' }, 'Yellow': { hex: '#F4EE2A' },
// ABS Colors
'ABS Azure': { hex: '#489FDF' },
'ABS Olive': { hex: '#748C45' },
'ABS Blue': { hex: '#0A2989' },
'ABS Tangerine Yellow': { hex: '#FFC72C' },
'ABS Navy Blue': { hex: '#0C2340' },
'ABS Orange': { hex: '#FF6A13' },
'ABS Bambu Green': { hex: '#00AE42' },
'ABS Red': { hex: '#C12E1F' },
'ABS White': { hex: '#FFFFFF' },
'ABS Black': { hex: '#000000' },
'ABS Silver': { hex: '#A6A9AA' },
// Translucent Colors
'Translucent Gray': { hex: '#B8B8B8' },
'Translucent Brown': { hex: '#C89A74' },
'Translucent Purple': { hex: '#C5A8D8' },
'Translucent Orange': { hex: '#FFB380' },
'Translucent Olive': { hex: '#A4B885' },
'Translucent Pink': { hex: '#F9B8D0' },
'Translucent Light Blue': { hex: '#A8D8F0' },
'Translucent Tea': { hex: '#D9C7A8' },
// 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 Ivory': { hex: '#FFFFF0' },
'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

@@ -40,6 +40,24 @@ export const bambuLabColors = {
"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": "#61C680",
"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",
"Silk Black": "#0A0A0A", "Silk Black": "#0A0A0A",
@@ -91,7 +109,12 @@ export const bambuLabColors = {
// Support Materials // Support Materials
"Natural": "#F5F5DC", "Natural": "#F5F5DC",
"Support White": "#F5F5F5", "Support White": "#F5F5F5",
"Support G": "#90CAF9" "Support G": "#90CAF9",
// Wood Colors
"Ochre Yellow": "#BC8B39",
"White Oak": "#D2CCA2",
"Clay Brown": "#8E621A"
}; };
// Colors grouped by finish type for easier selection // Colors grouped by finish type for easier selection
@@ -105,7 +128,11 @@ 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",
@@ -129,6 +156,9 @@ export const colorsByFinish = {
], ],
"Support": [ "Support": [
"Natural", "Support White", "Support G" "Natural", "Support White", "Support G"
],
"Wood": [
"Ochre Yellow", "White Oak", "Clay Brown"
] ]
}; };

View File

@@ -101,4 +101,35 @@ export const filamentService = {
}, },
}; };
export const colorRequestService = {
getAll: async () => {
const response = await api.get('/color-requests');
return response.data;
},
submit: async (request: {
color_name: string;
material_type: string;
finish_type?: string;
user_email: string;
user_phone: string;
user_name?: string;
description?: string;
reference_url?: string;
}) => {
const response = await api.post('/color-requests', request);
return response.data;
},
updateStatus: async (id: string, status: string, admin_notes?: string) => {
const response = await api.put(`/color-requests/${id}`, { status, admin_notes });
return response.data;
},
delete: async (id: string) => {
const response = await api.delete(`/color-requests/${id}`);
return response.data;
},
};
export default api; export default api;

View File

@@ -43,3 +43,57 @@
.animate-shimmer { .animate-shimmer {
animation: shimmer 3s ease-in-out infinite; animation: shimmer 3s ease-in-out infinite;
} }
/* Safari form styling fixes */
@layer base {
/* Remove Safari's default styling for form inputs */
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* Fix Safari select arrow */
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.7rem center;
background-size: 1em;
padding-right: 2.5rem;
}
/* Dark mode select arrow */
.dark select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
}
/* Ensure consistent border radius on iOS */
input,
select,
textarea {
border-radius: 0.375rem;
}
/* Remove iOS zoom on focus */
@media screen and (max-width: 768px) {
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
font-size: 16px;
}
}
}

View File

@@ -0,0 +1,198 @@
# S3 bucket for static website hosting
resource "aws_s3_bucket" "frontend" {
bucket = "${var.app_name}-frontend"
tags = {
Name = "${var.app_name}-frontend"
Environment = var.environment
}
}
# S3 bucket website configuration
resource "aws_s3_bucket_website_configuration" "frontend" {
bucket = aws_s3_bucket.frontend.id
index_document {
suffix = "index.html"
}
error_document {
key = "404.html"
}
}
# S3 bucket public access block (we'll use CloudFront OAC instead)
resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# CloudFront Origin Access Control
resource "aws_cloudfront_origin_access_control" "frontend" {
name = "${var.app_name}-frontend-oac"
description = "OAC for ${var.app_name} frontend"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# CloudFront Function for directory index rewrites
resource "aws_cloudfront_function" "index_rewrite" {
name = "${var.app_name}-index-rewrite"
runtime = "cloudfront-js-1.0"
comment = "Rewrite directory requests to index.html"
publish = true
code = file("${path.module}/cloudfront-function.js")
}
# CloudFront distribution
resource "aws_cloudfront_distribution" "frontend" {
enabled = true
is_ipv6_enabled = true
comment = "${var.app_name} frontend"
default_root_object = "index.html"
price_class = "PriceClass_100" # US, Canada, Europe only (cheapest)
# No aliases - Cloudflare will proxy to CloudFront's default domain
# aliases = var.domain_name != "" ? [var.domain_name, "www.${var.domain_name}"] : []
origin {
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.frontend.id}"
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 3600 # 1 hour
max_ttl = 86400 # 24 hours
compress = true
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.index_rewrite.arn
}
}
# Custom error responses for SPA routing
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 300
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 300
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
# Use default CloudFront certificate (we'll handle SSL via Cloudflare)
viewer_certificate {
cloudfront_default_certificate = true
# If you want CloudFront SSL, add ACM certificate here
# acm_certificate_arn = aws_acm_certificate.cert.arn
# ssl_support_method = "sni-only"
# minimum_protocol_version = "TLSv1.2_2021"
}
tags = {
Name = "${var.app_name}-frontend"
Environment = var.environment
}
}
# S3 bucket policy to allow CloudFront OAC access
resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowCloudFrontServicePrincipal"
Effect = "Allow"
Principal = {
Service = "cloudfront.amazonaws.com"
}
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.frontend.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
}
}
}
]
})
}
# Cloudflare DNS records for frontend
resource "cloudflare_record" "frontend_root" {
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
zone_id = data.cloudflare_zone.domain[0].id
name = "@"
type = "CNAME"
value = aws_cloudfront_distribution.frontend.domain_name
ttl = 1
proxied = true # Enable Cloudflare proxy for SSL and caching
comment = "CloudFront distribution for frontend"
}
resource "cloudflare_record" "frontend_www" {
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
zone_id = data.cloudflare_zone.domain[0].id
name = "www"
type = "CNAME"
value = aws_cloudfront_distribution.frontend.domain_name
ttl = 1
proxied = true # Enable Cloudflare proxy for SSL and caching
comment = "CloudFront distribution for frontend (www)"
}
# Cloudflare Transform Rule to rewrite Host header for CloudFront
resource "cloudflare_ruleset" "frontend_host_header_rewrite" {
count = var.domain_name != "" && var.cloudflare_api_token != "" ? 1 : 0
zone_id = data.cloudflare_zone.domain[0].id
name = "Rewrite Host header for CloudFront"
kind = "zone"
phase = "http_request_late_transform"
rules {
action = "rewrite"
expression = "(http.host eq \"${var.domain_name}\" or http.host eq \"www.${var.domain_name}\")"
description = "Rewrite Host header to CloudFront domain"
action_parameters {
headers {
name = "Host"
operation = "set"
value = aws_cloudfront_distribution.frontend.domain_name
}
}
}
}

View File

@@ -0,0 +1,18 @@
function handler(event) {
var request = event.request;
var uri = request.uri;
// Check whether the URI is missing a file extension
if (!uri.includes('.')) {
// Check if URI ends with /
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else {
// For Next.js static export, try .html first
// CloudFront will handle 404s via custom error response
request.uri += '.html';
}
}
return request;
}

View File

@@ -20,117 +20,98 @@ provider "cloudflare" {
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
} }
resource "aws_amplify_app" "filamenteka" { # ===== DEPRECATED: Amplify Hosting (Migrated to CloudFront + S3) =====
name = "filamenteka" # Amplify app was deleted due to broken GitHub integration
repository = var.github_repository # Frontend now hosted on CloudFront + S3 (see cloudfront-frontend.tf)
platform = "WEB" # Kept here for reference only
#
# GitHub access token for private repos (optional for public repos) # resource "aws_amplify_app" "filamenteka" {
# access_token = var.github_token # name = "filamenteka"
# repository = var.github_repository
# Build settings for Next.js # platform = "WEB"
build_spec = <<-EOT #
version: 1 # build_spec = <<-EOT
frontend: # version: 1
phases: # frontend:
preBuild: # phases:
commands: # preBuild:
- npm ci # commands:
- npm run security:check # - npm ci
build: # - npm run security:check
commands: # build:
- npm run build # commands:
- npm run test # - npm run build
artifacts: # - npm run test
baseDirectory: .next # artifacts:
files: # baseDirectory: .next
- '**/*' # files:
cache: # - '**/*'
paths: # cache:
- node_modules/**/* # paths:
- .next/cache/**/* # - node_modules/**/*
EOT # - .next/cache/**/*
# EOT
# Environment variables #
environment_variables = { # environment_variables = {
NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api" # Using Cloudflare proxied subdomain # NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api"
} # }
#
# Custom rules for single-page app # custom_rule {
custom_rule { # source = "/<*>"
source = "/<*>" # status = "404"
status = "404" # target = "/index.html"
target = "/index.html" # }
} #
# enable_branch_auto_build = true
# Enable branch auto build #
enable_branch_auto_build = true # tags = {
# Name = "Filamenteka"
tags = { # Environment = var.environment
Name = "Filamenteka" # "amplify:github_app_migration" = "opted_out"
Environment = var.environment # }
# Disable GitHub App migration prompt # }
"amplify:github_app_migration" = "opted_out" #
} # resource "aws_amplify_branch" "main" {
} # app_id = aws_amplify_app.filamenteka.id
# branch_name = "main"
# Main branch # enable_auto_build = true
resource "aws_amplify_branch" "main" { # environment_variables = {}
app_id = aws_amplify_app.filamenteka.id # stage = "PRODUCTION"
branch_name = "main" # tags = {
# Name = "Filamenteka-main"
# Enable auto build # Environment = var.environment
enable_auto_build = true # }
# }
# Environment variables specific to this branch (optional) #
environment_variables = {} # resource "aws_amplify_branch" "dev" {
# app_id = aws_amplify_app.filamenteka.id
stage = "PRODUCTION" # branch_name = "dev"
# enable_auto_build = true
tags = { # stage = "DEVELOPMENT"
Name = "Filamenteka-main" # tags = {
Environment = var.environment # Name = "Filamenteka-dev"
} # Environment = "development"
} # }
# }
# Development branch (optional) #
resource "aws_amplify_branch" "dev" { # resource "aws_amplify_domain_association" "filamenteka" {
app_id = aws_amplify_app.filamenteka.id # count = var.domain_name != "" ? 1 : 0
branch_name = "dev" # app_id = aws_amplify_app.filamenteka.id
# domain_name = var.domain_name
enable_auto_build = true # wait_for_verification = false
#
stage = "DEVELOPMENT" # sub_domain {
# branch_name = aws_amplify_branch.main.branch_name
tags = { # prefix = ""
Name = "Filamenteka-dev" # }
Environment = "development" #
} # sub_domain {
} # branch_name = aws_amplify_branch.main.branch_name
# prefix = "www"
# Custom domain (optional) # }
resource "aws_amplify_domain_association" "filamenteka" { #
count = var.domain_name != "" ? 1 : 0 # sub_domain {
# branch_name = aws_amplify_branch.dev.branch_name
app_id = aws_amplify_app.filamenteka.id # prefix = "dev"
domain_name = var.domain_name # }
wait_for_verification = false # }
# Map main branch to root domain
sub_domain {
branch_name = aws_amplify_branch.main.branch_name
prefix = ""
}
# Map main branch to www subdomain
sub_domain {
branch_name = aws_amplify_branch.main.branch_name
prefix = "www"
}
# Map dev branch to dev subdomain
sub_domain {
branch_name = aws_amplify_branch.dev.branch_name
prefix = "dev"
}
}

View File

@@ -1,22 +1,23 @@
output "app_id" { # ===== DEPRECATED: Amplify Outputs (removed after migration to CloudFront) =====
description = "The ID of the Amplify app" # output "app_id" {
value = aws_amplify_app.filamenteka.id # description = "The ID of the Amplify app"
} # value = aws_amplify_app.filamenteka.id
# }
output "app_url" { #
description = "The default URL of the Amplify app" # output "app_url" {
value = "https://main.${aws_amplify_app.filamenteka.default_domain}" # description = "The default URL of the Amplify app"
} # value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
# }
output "dev_url" { #
description = "The development branch URL" # output "dev_url" {
value = "https://dev.${aws_amplify_app.filamenteka.default_domain}" # description = "The development branch URL"
} # value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
# }
output "custom_domain_url" { #
description = "The custom domain URL (if configured)" # output "custom_domain_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured" # description = "The custom domain URL (if configured)"
} # value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
# }
output "rds_endpoint" { output "rds_endpoint" {
value = aws_db_instance.filamenteka.endpoint value = aws_db_instance.filamenteka.endpoint
@@ -59,3 +60,24 @@ output "aws_region" {
description = "AWS Region" description = "AWS Region"
} }
# CloudFront + S3 Frontend Outputs
output "s3_bucket_name" {
value = aws_s3_bucket.frontend.id
description = "S3 bucket name for frontend"
}
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.frontend.id
description = "CloudFront distribution ID"
}
output "cloudfront_domain_name" {
value = aws_cloudfront_distribution.frontend.domain_name
description = "CloudFront distribution domain name"
}
output "frontend_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
description = "Frontend URL"
}