18 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
27 changed files with 1880 additions and 255 deletions

167
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# 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
@@ -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 lint # Run ESLint
npm test # Run Jest tests
npm run test:watch # Run Jest in watch mode
# Security & Quality
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
npm run security:check # Check for credential leaks (scripts/security/security-check.js)
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
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
npm run migrate # Run pending migrations locally
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
./scripts/pre-commit.sh # Runs security, build, and test checks
# Deployment
scripts/deploy-api-update.sh # Deploy API to EC2 via AWS SSM (recommended)
scripts/deploy-frontend.sh # Manual frontend deployment helper
```
## Architecture
@@ -47,18 +51,26 @@ npm run migrate:clear # Clear migration history
- `/page.tsx` - Admin login
- `/dashboard/page.tsx` - Filament CRUD operations
- `/colors/page.tsx` - Color management
- `/requests/page.tsx` - Customer color requests
- `/src` - Source files
- `/components` - React components
- `/services/api.ts` - Axios instance with auth interceptors
- `/data` - Color definitions (bambuLabColors.ts, bambuLabColorsComplete.ts)
- `/types` - TypeScript type definitions
### API Structure
- `/api` - Node.js Express server (runs on EC2)
- `/server.js` - Main server file
- `/routes` - API endpoints for filaments, colors, auth
- `/api` - Node.js Express server (runs on EC2, port 80)
- `/server.js` - Main Express server with all routes inline
- Database: PostgreSQL on AWS RDS
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering
- `SaleManager` - Bulk sale management interface
- `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system
- `ColorRequestForm` - Customer color request form
- `ColorRequestModal` - Modal for color requests
### 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
### Frontend (AWS Amplify)
- Automatic deployment on push to main branch
- Build output: Static files in `/out` directory
- Config: `amplify.yml`, `next.config.js` (output: 'export')
- Security check runs during build (amplify.yml preBuild phase)
### API Server (EC2)
- Manual deployment via `scripts/deploy-api.sh`
- Server: `3.71.161.51`
- Deployment via `scripts/deploy-api-update.sh` (uses AWS SSM to push updates)
- Instance ID: `i-03956ecf32292d7d9`
- Server IP: `3.71.161.51`
- 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)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- User: `filamenteka_admin`
- Database: `filamenteka`
- Migrations in `/database/migrations/`
- Schema in `/database/schema.sql`
- Use `scripts/update-db-via-aws.sh` for running migrations on production
## Important Patterns
### API Communication
- All API calls use axios interceptors for auth (`src/services/api.ts`)
- Auth token stored in localStorage
- Automatic redirect on 401/403 in admin routes
- All API calls organized in service modules (`src/services/api.ts`)
- Services: `authService`, `colorService`, `filamentService`, `colorRequestService`
- 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
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
- Automatic row coloring based on filament color
- Special handling for gradient filaments
- Frontend color mappings in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
- Database color definitions in `colors` table with pricing (cena_refill, cena_spulna)
- 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
- React hooks for local state
- React hooks for local component state
- No global state management library
- Data fetching in components with useEffect
- Service layer handles all API interactions
### Testing
- Jest + React Testing Library
- Tests in `__tests__/` directory
- Config: `jest.config.js`, `jest.setup.js`
- Coverage goal: >80%
- Run with `npm test` or `npm run test:watch`
- Tests include: component tests, API integration tests, data consistency checks
## Environment Variables
@@ -135,34 +178,82 @@ colors: {
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
# API Server
DATABASE_URL=postgresql://...
# API Server (.env in /api directory)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=...
ADMIN_PASSWORD=...
NODE_ENV=production
PORT=80
```
## Security Considerations
- Admin routes protected by JWT authentication
- Password hashing with bcrypt
- SQL injection prevention via parameterized queries
- Credential leak detection in pre-commit hooks
- CORS configured for production domains only
- **Authentication**: JWT tokens with 24h expiry (hardcoded admin user for now)
- **Password**: Stored in environment variable ADMIN_PASSWORD (bcrypt ready for multi-user)
- **SQL Injection**: Prevented via parameterized queries in all database operations
- **Credential Leak Detection**: Pre-commit hook runs `scripts/security/security-check.js`
- **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:
1. Create migration file in `/database/migrations/`
2. Test locally first
3. Run migration on production via scripts
4. Update corresponding TypeScript types
1. Create migration file in `/database/migrations/` with sequential numbering (e.g., `019_add_new_feature.sql`)
2. Test locally first with `npm run migrate`
3. Run migration on production:
- 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/`:
- VPC and networking setup
- EC2 instance for API
- RDS PostgreSQL database
- Application Load Balancer
- ECR for Docker images
- Cloudflare DNS integration
- **VPC**: `vpc.tf` - Network setup with subnets and routing
- **EC2**: `ec2-api.tf` - API server instance (i-03956ecf32292d7d9)
- **RDS**: `rds.tf` - PostgreSQL database
- **ALB**: `alb.tf` - Application Load Balancer
- **ECR**: `ecr.tf` - Docker image registry
- **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', () => {
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 adminContent = readFileSync(adminPath, 'utf-8');

View File

@@ -3,7 +3,7 @@ import { join } from 'path';
describe('UI Features Tests', () => {
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');
// Check for color input
@@ -22,19 +22,19 @@ describe('UI Features Tests', () => {
});
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');
// Check for number inputs for quantities
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
expect(adminContent).toContain('Refill');
expect(adminContent).toContain('Spulna');
expect(adminContent).toContain('Refil');
expect(adminContent).toContain('Špulna');
expect(adminContent).toContain('Ukupna količina');
});
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');
// Check for number input
@@ -44,7 +44,7 @@ describe('UI Features Tests', () => {
});
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');
// Check for material select dropdown
@@ -54,7 +54,7 @@ describe('UI Features Tests', () => {
});
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');

View File

@@ -161,7 +161,14 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const spulnaNum = parseInt(spulna) || 0;
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
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
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,
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]);
} catch (error) {
console.error('Error updating filament:', error);

View File

@@ -6,6 +6,7 @@ import { filamentService, colorService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { SaleManager } from '@/src/components/SaleManager';
import { BulkFilamentPriceEditor } from '@/src/components/BulkFilamentPriceEditor';
// Removed unused imports for Bambu Lab color categorization
import '@/src/styles/select.css';
@@ -32,7 +33,7 @@ const REFILL_ONLY_COLORS = [
// Helper function to check if a filament is spool-only
const isSpoolOnly = (finish?: string, type?: string): boolean => {
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || (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
@@ -45,6 +46,21 @@ const isRefillOnly = (color: string, finish?: string, type?: string): boolean =>
if (finish === 'Translucent') {
return false;
}
// Specific type/finish/color combinations that are refill-only
if (type === 'ABS' && finish === 'GF' && (color === 'Yellow' || color === 'Orange')) {
return true;
}
if (type === 'TPU' && finish === '95A HF') {
return true;
}
// All colors starting with "Matte " prefix are refill-only
if (color.startsWith('Matte ')) {
return true;
}
// Galaxy and Basic colors have spools available (not refill-only)
if (finish === 'Galaxy' || finish === 'Basic') {
return false;
}
return REFILL_ONLY_COLORS.includes(color);
};
@@ -67,13 +83,14 @@ interface FilamentWithId extends Filament {
// Finish options by filament type
const FINISH_OPTIONS_BY_TYPE: Record<string, string[]> = {
'ABS': ['GF', 'Bez Finisha'],
'PLA': ['85A', '90A', '95A HF', 'Aero', 'Basic', 'Basic Gradient', 'CF', 'FR', 'Galaxy', 'GF', 'Glow', 'HF', 'Marble', 'Matte', 'Metal', 'Silk Multi-Color', 'Silk+', 'Sparkle', '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'],
'PETG': ['Basic', 'CF', 'FR', 'HF', 'Translucent'],
'PC': ['CF', 'FR', 'Bez Finisha'],
'ASA': ['Bez Finisha'],
'PA': ['CF', 'GF', 'Bez Finisha'],
'PA6': ['CF', 'GF'],
'PAHT': ['CF', 'Bez Finisha'],
'PPA': ['CF'],
'PVA': ['Bez Finisha'],
'HIPS': ['Bez Finisha']
@@ -119,13 +136,16 @@ export default function AdminDashboard() {
// Check authentication
useEffect(() => {
// Wait for component to mount to avoid SSR issues with localStorage
if (!mounted) return;
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || Date.now() > parseInt(expiry)) {
router.push('/upadaj');
window.location.href = '/upadaj';
}
}, [router]);
}, [mounted]);
// Fetch filaments
const fetchFilaments = async () => {
@@ -377,6 +397,10 @@ export default function AdminDashboard() {
selectedFilaments={selectedFilaments}
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"
@@ -460,8 +484,8 @@ export default function AdminDashboard() {
<option value="boja-desc">Sortiraj po: Boja (Z-A)</option>
<option value="tip-asc">Sortiraj po: Tip (A-Z)</option>
<option value="tip-desc">Sortiraj po: Tip (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finish (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finish (Z-A)</option>
<option value="finish-asc">Sortiraj po: Finiš (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finiš (Z-A)</option>
<option value="created_at-desc">Sortiraj po: Poslednje dodano</option>
<option value="created_at-asc">Sortiraj po: Prvo dodano</option>
<option value="updated_at-desc">Sortiraj po: Poslednje ažurirano</option>
@@ -507,16 +531,16 @@ export default function AdminDashboard() {
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
@@ -694,6 +718,9 @@ function FilamentForm({
cena_spulna: 0,
});
// Track if this is the initial load to prevent price override
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Update form when filament prop changes
useEffect(() => {
// Extract prices from the cena field if it exists (format: "3499/3999" or just "3499")
@@ -723,10 +750,19 @@ function FilamentForm({
cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999,
});
// Reset initial load flag when filament changes
setIsInitialLoad(true);
}, [filament]);
// Update prices when color selection changes
// Update prices when color selection changes (but not on initial load)
useEffect(() => {
// Skip price update on initial load to preserve existing filament prices
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
if (formData.boja && availableColors.length > 0) {
const colorData = availableColors.find(c => c.name === formData.boja);
if (colorData) {
@@ -866,7 +902,7 @@ function FilamentForm({
</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
name="finish"
value={formData.finish}
@@ -874,7 +910,7 @@ function FilamentForm({
required
className="custom-select w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi finish</option>
<option value="">Izaberi finiš</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
@@ -945,7 +981,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-green-600 dark:text-green-400">Cena Refill</span>
<span className="text-green-600 dark:text-green-400">Cena Refila</span>
</label>
<input
type="number"
@@ -966,7 +1002,7 @@ function FilamentForm({
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
<span className="text-blue-500 dark:text-blue-400">Cena Spulna</span>
<span className="text-blue-500 dark:text-blue-400">Cena Špulne</span>
</label>
<input
type="number"
@@ -988,9 +1024,9 @@ function FilamentForm({
{/* Quantity inputs for refill, vakuum, and otvoreno */}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Refill
Refil
{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>
<input
@@ -1012,7 +1048,7 @@ function FilamentForm({
<div>
<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) && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">(samo refil postoji)</span>
)}

View File

@@ -166,14 +166,14 @@ export default function Home() {
</a>
<a
href="tel:+381677102845"
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Pozovi +381 67 710 2845
Pozovi +381 63 103 1048
</a>
<button
@@ -205,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
key={resetKey}
filaments={filaments}
@@ -213,18 +249,18 @@ export default function Home() {
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col sm:flex-row justify-center items-center gap-6">
<div className="text-center sm:text-left">
<div className="flex flex-col justify-center items-center gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
<a
href="tel:+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"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
+381 67 710 2845
+381 63 103 1048
</a>
</div>
</div>

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export default function ColorRequestsAdmin() {
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
router.push('/upadaj');
window.location.href = '/upadaj';
}
};
@@ -125,7 +125,7 @@ export default function ColorRequestsAdmin() {
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Zahtevi za Boje</h1>
<div className="space-x-4">
<Link
href="/upadaj/dashboard"
href="/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar

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;

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "./scripts/kill-dev.sh && next dev",
"build": "next build",
"start": "next start",
"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

@@ -14,6 +14,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
@@ -37,6 +38,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
@@ -136,12 +138,13 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email (opciono)
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"
@@ -150,6 +153,23 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
</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

View File

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

View File

@@ -33,6 +33,8 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Charcoal': { hex: '#000000' },
'Cherry Pink': { hex: '#E9B6CC' },
'Chocolate': { hex: '#4A3729' },
'Classic Birch': { hex: '#E8D5B7' },
'Classic Gold Sparkle': { hex: '#E4BD68' },
'Clay Brown': { hex: '#8E621A' },
'Clear': { hex: '#FAFAFA' },
'Clear Black': { hex: '#5A5161' },
@@ -70,6 +72,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Indigo Purple': { hex: '#482A60' },
'Iridium Gold Metallic': { hex: '#B39B84' },
'Iris Purple': { hex: '#69398E' },
'IronGray Metallic': { hex: '#6B6C6F' },
'Ivory White': { hex: '#FFFFFF' },
'Jade White': { hex: '#FFFFFF' },
'Jeans Blue': { hex: '#6E88BC' },
@@ -99,6 +102,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Nardo Gray': { hex: '#747474' },
'Navy Blue': { hex: '#0C2340' },
'Nebulae': { hex: '#424379' },
'Nebulane': { hex: '#424379' },
'Neon City': { hex: '#0047BB' },
'Neon Green': { hex: '#ABFF1E' },
'Neon Orange': { hex: '#F68A1B' },
@@ -141,6 +145,48 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Oak': { hex: '#D2CCA2' },
'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
'Unknown': { hex: '#CCCCCC' }
};

View File

@@ -40,6 +40,24 @@ export const bambuLabColors = {
"Matte Navy": "#1A237E",
"Matte Coral": "#FF5252",
// Matte Colors - New 2025
"Matte Apple Green": "#C6E188",
"Matte Bone White": "#C8C5B6",
"Matte Caramel": "#A4845C",
"Matte Dark Blue": "#042F56",
"Matte Dark Brown": "#7D6556",
"Matte Dark Chocolate": "#4A3729",
"Matte Dark Green": "#68724D",
"Matte Dark Red": "#BB3D43",
"Matte Grass Green": "#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 White": "#FEFEFE",
"Silk Black": "#0A0A0A",
@@ -91,7 +109,12 @@ export const bambuLabColors = {
// Support Materials
"Natural": "#F5F5DC",
"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
@@ -105,7 +128,11 @@ export const colorsByFinish = {
"Matte": [
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral"
"Matte 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 White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
@@ -129,6 +156,9 @@ export const colorsByFinish = {
],
"Support": [
"Natural", "Support White", "Support G"
],
"Wood": [
"Ochre Yellow", "White Oak", "Clay Brown"
]
};

View File

@@ -111,7 +111,8 @@ export const colorRequestService = {
color_name: string;
material_type: string;
finish_type?: string;
user_email?: string;
user_email: string;
user_phone: string;
user_name?: string;
description?: string;
reference_url?: string;

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

View File

@@ -1,22 +1,23 @@
output "app_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"
value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
}
output "dev_url" {
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)"
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
}
# ===== DEPRECATED: Amplify Outputs (removed after migration to CloudFront) =====
# output "app_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"
# value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
# }
#
# output "dev_url" {
# 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)"
# value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
# }
output "rds_endpoint" {
value = aws_db_instance.filamenteka.endpoint
@@ -59,3 +60,24 @@ output "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"
}