3 Commits

Author SHA1 Message Date
DaX
f1f3a65dfd Update documentation and clean up component code 2025-09-24 18:15:19 +02:00
DaX
59304a88f4 Center tabs and move color request to dedicated tab
- Centered tab navigation for better visual balance
- Created dedicated Color Request tab with informative layout
- Removed standalone color request button from main actions
- Added statistics and info to color request section
- Shortened 'Oprema i Delovi' to just 'Oprema' for cleaner tabs
2025-08-29 13:22:34 +02:00
DaX
2fefc805ef Add tabbed interface with Printers and Gear/Accessories sections
- Created tabbed navigation component for switching between sections
- Added Printers table with card layout and request modal
- Added Gear/Accessories table with filtering and request modal
- Integrated tabs into main page with icons
- Added mock data for printers and gear items
- Created request modals with required contact fields
2025-08-29 13:12:12 +02:00
102 changed files with 4248 additions and 12987 deletions

View File

@@ -1,15 +0,0 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": { "jsx": true }
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "warn"
},
"ignorePatterns": ["node_modules/", "out/", ".next/", "api/"]
}

View File

@@ -1,110 +0,0 @@
name: Deploy
on:
push:
branches: [main]
env:
AWS_REGION: eu-central-1
S3_BUCKET: filamenteka-frontend
INSTANCE_ID: i-03956ecf32292d7d9
NEXT_PUBLIC_API_URL: https://api.filamenteka.rs/api
NEXT_PUBLIC_MATOMO_URL: https://analytics.demirix.dev
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changes
id: changes
run: |
FRONTEND_CHANGED=false
API_CHANGED=false
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then
FRONTEND_CHANGED=true
fi
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then
API_CHANGED=true
fi
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT
echo "Frontend changed: $FRONTEND_CHANGED"
echo "API changed: $API_CHANGED"
# ── Frontend Deploy ──────────────────────────────────────────────
- name: Setup Node.js
if: steps.changes.outputs.frontend == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
if: steps.changes.outputs.frontend == 'true'
run: npm ci
- name: Security check & tests
if: steps.changes.outputs.frontend == 'true'
run: |
npm run security:check
npm test -- --passWithNoTests
- name: Build Next.js
if: steps.changes.outputs.frontend == 'true'
run: npm run build
- name: Setup AWS
if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true'
run: |
pip3 install -q --break-system-packages awscli
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
aws configure set region eu-central-1
- name: Deploy to S3 & invalidate CloudFront
if: steps.changes.outputs.frontend == 'true'
run: |
aws s3 sync out/ s3://$S3_BUCKET/ \
--delete \
--exclude "*" \
--include "*.html" \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html"
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
--cache-control "public, max-age=31536000, immutable"
aws s3 sync out/ s3://$S3_BUCKET/ \
--exclude "*.html" \
--exclude "_next/*" \
--cache-control "public, max-age=86400"
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
# ── API Deploy ───────────────────────────────────────────────────
- name: Deploy API via SSM
if: steps.changes.outputs.api == 'true'
run: |
aws ssm send-command \
--region $AWS_REGION \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"cd /home/ubuntu/filamenteka-api",
"cp server.js server.js.backup",
"curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js",
"sudo systemctl restart node-api",
"sudo systemctl status node-api"
]' \
--output json
echo "API deploy command sent via SSM"

6
.gitignore vendored
View File

@@ -58,9 +58,3 @@ temp-*.sh
# Lambda packages
lambda/*.zip
lambda/**/node_modules/
# MCP config (local dev tooling)
.mcp.json
# Images/screenshots
*.png

275
CLAUDE.md
View File

@@ -1,132 +1,231 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance when working with code in this repository.
## Project Overview
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export to `/out`)
- **Backend**: Node.js Express API server (`/api/server.js`) with PostgreSQL database
- **Infrastructure**: AWS (CloudFront + S3 for frontend, EC2 for API, RDS for database)
- **Repository**: `git.demirix.dev/dax/Filamenteka`
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments with:
- **Frontend**: Next.js 15 app with React 19, TypeScript 5.2.2, and Tailwind CSS (static export)
- **Backend**: Node.js Express API server with PostgreSQL database
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database), managed via Terraform
## Critical Rules
- NEVER mention ANY author in commits. No author tags or attribution of any kind
- NEVER mention ANY author in commits. No author tags or attribution
- NEVER mention AI/assistant names anywhere
- Keep commit messages clean and professional with NO attribution
- Build for AMD64 Linux when deploying (development is on ARM macOS)
- Always run security checks before commits (Husky pre-commit hook does this automatically)
- Always run security checks before commits
- NEVER use mock data - all tests must use real APIs/data
## Common Commands
```bash
# Development
npm run dev # Start Next.js dev server (port 3000)
npm run build # Build static export to /out
npm run lint # ESLint
npm test # Jest tests
npm run test:watch # Jest watch mode
npm test -- --testPathPattern=__tests__/components/ColorCell # Run single test file
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 (includes no-mock-data validation)
# API Development
cd api && npm run dev # Start API with nodemon (port 4000)
# Security & Quality
npm run security:check # Check for credential leaks
npm run test:build # Test if build succeeds
./scripts/pre-commit.sh # Full pre-commit validation (security, build, tests)
# Quality Gates
npm run security:check # Credential leak detection
npm run test:build # Verify build succeeds without deploying
npx tsc --noEmit # TypeScript type checking
# Database Migrations
npm run migrate # Run pending migrations
npm run migrate:clear # Clear migration history
# Database
npm run migrate # Run pending migrations locally
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
# API Server Development
cd api && npm start # Start API server locally (port 3001)
cd api && npm run dev # Start API server with nodemon
# Deployment (auto via CI/CD on push to main, or manual)
scripts/deploy-api-update.sh # Deploy API to EC2 via AWS SSM
scripts/deploy-frontend.sh # Manual frontend deploy to S3/CloudFront
# Deployment
./scripts/deploy-api.sh # Deploy API to EC2 (builds AMD64 Docker image)
```
## Architecture
### Frontend (Next.js App Router)
- `/app/page.tsx` — Public filament inventory table (home page)
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/requests` (customer color requests)
- `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect)
- `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component)
- `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
### API (`/api/server.js`)
Single-file Express server running on EC2 (port 80). All routes inline.
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
### Database (PostgreSQL on RDS)
Key schemas and constraints:
### Frontend Structure (Next.js App Router)
```
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
color_requests: id, color_name, message, contact_name, contact_phone, status
/app
├── page.tsx # Public filament inventory table
├── layout.tsx # Root layout with providers
└── upadaj/ # Admin panel (JWT protected)
├── page.tsx # Admin login
├── dashboard/page.tsx # Filament CRUD operations
├── colors/page.tsx # Color management
└── requests/page.tsx # Color request management
```
**Critical constraints:**
- `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
- `kolicina = refill + spulna` — enforced by check constraint
- Migrations in `/database/migrations/` with sequential numbering (currently up to `020_`)
### API Structure
```
/api
├── server.js # Express server (port 3001)
├── routes/
│ ├── filaments.js # Filament CRUD + bulk operations
│ ├── colors.js # Color management
│ ├── colorRequests.js # Color request system
│ └── auth.js # JWT authentication
└── middleware/
└── auth.js # JWT verification middleware
```
## Key Patterns
### Key Components
- `FilamentTableV2` - Main inventory display with sorting/filtering/searching
- `SaleManager` - Bulk sale management with countdown timers
- `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Multi-criteria filtering system
- `ColorRequestModal` - Public color request form
### Color Management (two-layer system)
1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
3. Color names must match exactly between `colors.name`, `filaments.boja`, and `bambuLabColors.ts` keys.
4. The `ColorCell` component renders table row backgrounds from these mappings.
### Data Models
### Adding New Colors
1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
2. Add hex mapping to `src/data/bambuLabColors.ts`
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
#### Filament Schema (PostgreSQL)
```sql
filaments: {
id: UUID PRIMARY KEY,
tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk)
boja: VARCHAR(100), # Color name (links to colors.name)
boja_hex: VARCHAR(7), # Color hex code
refill: INTEGER, # Refill spool count
spulna: INTEGER, # Regular spool count
kolicina: INTEGER, # Total quantity (refill + spulna)
cena: VARCHAR(50), # Price string
sale_active: BOOLEAN, # Sale status
sale_percentage: INTEGER,# Sale discount percentage
sale_end_date: TIMESTAMP # Sale expiry
}
```
#### Color Schema
```sql
colors: {
id: UUID PRIMARY KEY,
name: VARCHAR(100) UNIQUE, # Color name (must match filament.boja)
hex: VARCHAR(7), # Hex color code
cena_refill: INTEGER, # Refill price (default: 3499)
cena_spulna: INTEGER # Regular price (default: 3999)
}
```
#### Color Requests Schema
```sql
color_requests: {
id: UUID PRIMARY KEY,
color_name: VARCHAR(100),
email: VARCHAR(255),
message: TEXT,
status: VARCHAR(50), # pending/approved/rejected
created_at: TIMESTAMP
}
```
## Deployment
### Frontend (AWS Amplify)
- Automatic deployment on push to main branch
- Build output: Static files in `/out` directory
- Build command: `npm run build`
- Config: `amplify.yml`, `next.config.js` (output: 'export')
### API Server (EC2)
- Manual deployment via `./scripts/deploy-api.sh`
- Docker containerized (Node.js 18 Alpine)
- Server: `3.71.161.51`
- Domain: `api.filamenteka.rs`
- **Important**: Build for AMD64 architecture when deploying from ARM Mac
### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- Migrations in `/database/migrations/` (numbered sequence)
- Schema in `/database/schema.sql`
- Connection via `DATABASE_URL` environment variable
## Important Patterns
### API Communication
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`
- Auth token stored in localStorage with 24h expiry
- Cache busting on filament fetches via timestamp query param
- All API calls use axios with interceptors (`src/services/api.ts`)
- Auth token stored in localStorage as 'adminToken'
- Automatic redirect on 401/403 in admin routes
- Base URL from `NEXT_PUBLIC_API_URL` env variable
## CI/CD (Gitea Actions)
### Color Management
- Primary source: `src/data/bambuLabColors.ts` (hex mappings)
- Extended data: `src/data/bambuLabColorsComplete.ts`
- Refill-only colors: `src/data/refillOnlyColors.ts`
- Automatic row coloring based on filament.boja_hex
- Special gradient handling for multi-color filaments
`.gitea/workflows/deploy.yml` triggers on push to `main`. Auto-detects changes via `git diff HEAD~1`:
- **Frontend changes** (anything outside `api/`): security check → tests → build → S3 deploy (3-tier cache: HTML no-cache, `_next/` immutable, rest 24h) → CloudFront invalidation
- **API changes** (`api/` files): SSM command to EC2 to pull `server.js` from Gitea and restart `node-api` service
- Both run if both changed in a single push
### State Management
- React hooks for local state (no global state library)
- Data fetching with useEffect in components
- Form state managed with controlled components
- Admin auth state in localStorage
## Deployment Details
| Component | Target | Method |
|-----------|--------|--------|
| Frontend | S3 `filamenteka-frontend` → CloudFront | Static export, OAC-protected |
| API | EC2 `i-03956ecf32292d7d9` | SSM, systemd `node-api` service |
| Database | RDS `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com` | Migrations via `scripts/update-db-via-aws.sh` |
| DNS | Cloudflare | `api.filamenteka.rs` → EC2 |
| IaC | `/terraform/` | VPC, EC2, RDS, ALB, CloudFront, Cloudflare |
### Testing Strategy
- Jest + React Testing Library for component tests
- Special `no-mock-data.test.ts` enforces real API usage
- Integration tests connect to real database
- Coverage reports in `/coverage/`
- Test files in `__tests__/` directories
## Environment Variables
```bash
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
NEXT_PUBLIC_MATOMO_SITE_ID=7
# API Server (.env in /api)
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=...
ADMIN_PASSWORD=...
# API Server (.env)
DATABASE_URL=postgresql://username:password@host/database
JWT_SECRET=your-secret-key
NODE_ENV=production
PORT=80
PORT=3001
```
## Pre-commit Hooks (Husky)
## Security Considerations
`scripts/pre-commit.sh` runs automatically and blocks commits that fail:
1. Author mention check (blocks attribution)
2. Security check (credential leaks)
3. Build test (ensures compilation)
4. Unit tests (`jest --passWithNoTests`)
- JWT authentication for admin routes (24h expiry)
- Password hashing with bcryptjs (10 rounds)
- SQL injection prevention via parameterized queries
- Credential leak detection in pre-commit hooks
- CORS configured for production domains only
- Author mention detection prevents attribution in commits
## Database Operations
When modifying the database:
1. Create numbered migration file in `/database/migrations/`
2. Test locally with `npm run migrate`
3. Deploy to production via SSH or migration script
4. Update TypeScript interfaces in `src/types/`
5. Update relevant data files in `src/data/`
## Terraform Infrastructure
Infrastructure as Code in `/terraform/`:
- VPC with public/private subnets
- EC2 instance with Application Load Balancer
- RDS PostgreSQL instance
- ECR for Docker image registry
- Cloudflare DNS integration
- Environment separation (dev/prod)
## Special Considerations
### ARM to AMD64 Builds
When deploying from ARM Mac to AMD64 Linux:
- Docker builds must specify `--platform linux/amd64`
- Deployment scripts handle architecture conversion
- Test builds locally with `docker buildx`
### Color Data Synchronization
- Colors must exist in both database and frontend data files
- Use migration scripts to sync color data
- Validate consistency with data consistency tests
### Sale Management
- Bulk sale operations affect multiple filaments
- Sale countdown timers update in real-time
- Prices automatically calculated with discounts
- Sale end dates trigger automatic deactivation

View File

@@ -4,22 +4,21 @@ A web application for tracking Bambu Lab filament inventory with automatic color
## Features
- Automatic Color Coding - Table rows are automatically colored based on filament colors
- Search & Filter - Quick search across all filament properties
- Sortable Columns - Click headers to sort by any column
- Gradient Support - Special handling for gradient filaments like Cotton Candy Cloud
- Responsive Design - Works on desktop and mobile devices
- Sale Management - Bulk sale pricing with countdown timers
- Admin Panel - Protected dashboard for inventory management
- Spool Types - Support for both regular and refill spools
- 🎨 **Automatic Color Coding** - Table rows are automatically colored based on filament colors
- 🔍 **Search & Filter** - Quick search across all filament properties
- 📊 **Sortable Columns** - Click headers to sort by any column
- 🌈 **Gradient Support** - Special handling for gradient filaments like Cotton Candy Cloud
- 📱 **Responsive Design** - Works on desktop and mobile devices
- 💰 **Sale Management** - Bulk sale pricing with countdown timers
- 🔐 **Admin Panel** - Protected dashboard for inventory management
- 📦 **Spool Types** - Support for both regular and refill spools
## Technology Stack
- **Frontend**: Next.js + React + TypeScript + Tailwind CSS
- **Backend**: Node.js API server (Express)
- **Database**: PostgreSQL (AWS RDS)
- **Infrastructure**: AWS CloudFront + S3 (Frontend), EC2 (API), RDS (Database)
- **CI/CD**: Gitea Actions
- **Infrastructure**: AWS Amplify (Frontend), EC2 (API), RDS (Database)
- **IaC**: Terraform
## Prerequisites
@@ -28,14 +27,15 @@ A web application for tracking Bambu Lab filament inventory with automatic color
- PostgreSQL (for local development)
- AWS Account (for deployment)
- Terraform 1.0+ (for infrastructure)
- GitHub account
## Setup Instructions
### 1. Clone the Repository
```bash
git clone https://git.demirix.dev/DaX/Filamenteka.git
cd Filamenteka
git clone https://github.com/yourusername/filamenteka.git
cd filamenteka
```
### 2. Install Dependencies
@@ -90,7 +90,7 @@ The filament table displays these columns:
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
- **Refill** - Number of refill spools
- **Spulna** - Number of regular spools
- **Kolicina** - Total quantity (refill + spulna)
- **Količina** - Total quantity (refill + spulna)
- **Cena** - Price per unit
- **Sale** - Active sale percentage and end date
@@ -106,20 +106,16 @@ Unknown colors default to light gray.
## Deployment
Pushing to `main` triggers automatic deployment via Gitea Actions:
- Frontend changes are built, tested, and deployed to S3/CloudFront
- API changes are deployed to EC2 via AWS SSM
Manual deployment is also available:
Push to the main branch to trigger automatic deployment:
```bash
# Frontend
./scripts/deploy-frontend.sh
# API
./scripts/deploy-api-update.sh
git add .
git commit -m "Update filament colors"
git push origin main
```
Amplify will automatically build and deploy your changes.
## Admin Panel
Access the admin panel at `/upadaj` for:
@@ -159,7 +155,9 @@ Deploy API server updates:
```bash
# Use the deployment script
./scripts/deploy-api-update.sh
./scripts/deploy-api.sh
# Or deploy manually to EC2 instance
```
## Troubleshooting
@@ -183,3 +181,12 @@ Deploy API server updates:
## License
MIT
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request

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', 'dashboard', 'page.tsx');
const adminPath = join(process.cwd(), 'app', 'upadaj', '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', 'dashboard', 'page.tsx');
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', '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', 'dashboard', 'page.tsx');
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', '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('Refil');
expect(adminContent).toContain('Špulna');
expect(adminContent).toContain('Refill');
expect(adminContent).toContain('Spulna');
expect(adminContent).toContain('Ukupna količina');
});
it('should have number input for quantity', () => {
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', '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', 'dashboard', 'page.tsx');
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', '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', 'dashboard', 'page.tsx');
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');

18
amplify.yml Normal file
View File

@@ -0,0 +1,18 @@
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci
- npm run security:check
build:
commands:
- npm run build
artifacts:
baseDirectory: out
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- .next/cache/**/*

257
api/package-lock.json generated
View File

@@ -79,23 +79,23 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
@@ -224,24 +224,24 @@
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -249,10 +249,6 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
@@ -284,9 +280,9 @@
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -379,39 +375,39 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
@@ -438,17 +434,17 @@
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
@@ -594,23 +590,19 @@
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
@@ -694,12 +686,12 @@
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
@@ -722,9 +714,9 @@
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
@@ -733,12 +725,12 @@
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
@@ -882,9 +874,9 @@
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -911,9 +903,9 @@
}
},
"node_modules/nodemon/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -994,14 +986,14 @@
"license": "MIT"
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"version": "8.16.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
"integrity": "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.2",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -1009,7 +1001,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
"pg-cloudflare": "^1.2.6"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -1021,16 +1013,16 @@
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.6.tgz",
"integrity": "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -1043,18 +1035,18 @@
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.2.tgz",
"integrity": "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==",
"license": "MIT"
},
"node_modules/pg-types": {
@@ -1105,9 +1097,9 @@
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1155,12 +1147,12 @@
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
@@ -1179,15 +1171,15 @@
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
@@ -1233,9 +1225,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1245,29 +1237,38 @@
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1275,15 +1276,15 @@
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
@@ -1390,9 +1391,9 @@
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"

View File

@@ -161,14 +161,7 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
const spulnaNum = parseInt(spulna) || 0;
const kolicina = refillNum + spulnaNum;
// Check if sale fields are provided in the request
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
let result;
if (hasSaleFields) {
// Update with sale fields if they are provided
result = await pool.query(
const result = await pool.query(
`UPDATE filaments
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
refill = $5, spulna = $6, kolicina = $7, cena = $8,
@@ -179,18 +172,6 @@ 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);
@@ -385,383 +366,6 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
}
});
// Slug generation helper
const generateSlug = (name) => {
const base = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const suffix = Math.random().toString(36).substring(2, 8);
return `${base}-${suffix}`;
};
// Products endpoints
// List/filter products (PUBLIC)
app.get('/api/products', async (req, res) => {
try {
const { category, condition, printer_model, in_stock, search } = req.query;
const conditions = [];
const params = [];
let paramIndex = 1;
if (category) {
conditions.push(`p.category = $${paramIndex++}`);
params.push(category);
}
if (condition) {
conditions.push(`p.condition = $${paramIndex++}`);
params.push(condition);
}
if (printer_model) {
conditions.push(`EXISTS (
SELECT 1 FROM product_printer_compatibility ppc
JOIN printer_models pm ON pm.id = ppc.printer_model_id
WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++}
)`);
params.push(printer_model);
}
if (in_stock === 'true') {
conditions.push(`p.quantity > 0`);
} else if (in_stock === 'false') {
conditions.push(`p.quantity = 0`);
}
if (search) {
conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT p.*,
COALESCE(
json_agg(
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
) FILTER (WHERE pm.id IS NOT NULL),
'[]'
) AS compatible_printers
FROM products p
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
${whereClause}
GROUP BY p.id
ORDER BY p.created_at DESC`,
params
);
res.json(result.rows);
} catch (error) {
console.error('Error fetching products:', error);
res.status(500).json({ error: 'Failed to fetch products' });
}
});
// Get single product (PUBLIC)
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`SELECT p.*,
COALESCE(
json_agg(
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
) FILTER (WHERE pm.id IS NOT NULL),
'[]'
) AS compatible_printers
FROM products p
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
WHERE p.id = $1
GROUP BY p.id`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching product:', error);
res.status(500).json({ error: 'Failed to fetch product' });
}
});
// Create product (auth required)
app.post('/api/products', authenticateToken, async (req, res) => {
const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body;
try {
const slug = generateSlug(name);
const result = await pool.query(
`INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url]
);
const product = result.rows[0];
// Insert printer compatibility entries if provided
if (printer_model_ids && printer_model_ids.length > 0) {
const compatValues = printer_model_ids
.map((_, i) => `($1, $${i + 2})`)
.join(', ');
await pool.query(
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
[product.id, ...printer_model_ids]
);
}
res.json(product);
} catch (error) {
console.error('Error creating product:', error);
res.status(500).json({ error: 'Failed to create product' });
}
});
// Update product (auth required)
app.put('/api/products/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body;
try {
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) {
result = await pool.query(
`UPDATE products
SET name = $1, description = $2, category = $3, condition = $4,
price = $5, quantity = $6, image_url = $7,
sale_percentage = $8, sale_active = $9,
sale_start_date = $10, sale_end_date = $11,
updated_at = CURRENT_TIMESTAMP
WHERE id = $12 RETURNING *`,
[name, description, category, condition, price, parseInt(quantity) || 0, image_url,
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
);
} else {
result = await pool.query(
`UPDATE products
SET name = $1, description = $2, category = $3, condition = $4,
price = $5, quantity = $6, image_url = $7,
updated_at = CURRENT_TIMESTAMP
WHERE id = $8 RETURNING *`,
[name, description, category, condition, price, parseInt(quantity) || 0, image_url, id]
);
}
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
// Update printer compatibility if provided
if (printer_model_ids !== undefined) {
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
if (printer_model_ids && printer_model_ids.length > 0) {
const compatValues = printer_model_ids
.map((_, i) => `($1, $${i + 2})`)
.join(', ');
await pool.query(
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
[id, ...printer_model_ids]
);
}
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating product:', error);
res.status(500).json({ error: 'Failed to update product' });
}
});
// Delete product (auth required)
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ success: true });
} catch (error) {
console.error('Error deleting product:', error);
res.status(500).json({ error: 'Failed to delete product' });
}
});
// Bulk sale update for products (auth required)
app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => {
const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
try {
let query;
let params;
if (productIds && productIds.length > 0) {
query = `
UPDATE products
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = ANY($5)
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds];
} else {
query = `
UPDATE products
SET sale_percentage = $1,
sale_active = $2,
sale_start_date = $3,
sale_end_date = $4,
updated_at = CURRENT_TIMESTAMP
RETURNING *`;
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
}
const result = await pool.query(query, params);
res.json({
success: true,
updatedCount: result.rowCount,
updatedProducts: result.rows
});
} catch (error) {
console.error('Error updating product sale:', error);
res.status(500).json({ error: 'Failed to update product sale' });
}
});
// Printer models endpoints
// List all printer models (PUBLIC)
app.get('/api/printer-models', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
res.json(result.rows);
} catch (error) {
console.error('Error fetching printer models:', error);
res.status(500).json({ error: 'Failed to fetch printer models' });
}
});
// Analytics endpoints
// Aggregated inventory stats (auth required)
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
try {
const filamentStats = await pool.query(`
SELECT
COUNT(*) AS total_skus,
COALESCE(SUM(kolicina), 0) AS total_units,
COALESCE(SUM(refill), 0) AS total_refills,
COALESCE(SUM(spulna), 0) AS total_spools,
COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus,
COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value
FROM filaments
`);
const productStats = await pool.query(`
SELECT
COUNT(*) AS total_skus,
COALESCE(SUM(quantity), 0) AS total_units,
COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus,
COALESCE(SUM(quantity * price), 0) AS total_inventory_value,
COUNT(*) FILTER (WHERE category = 'printer') AS printers,
COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates,
COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles,
COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts,
COUNT(*) FILTER (WHERE category = 'accessory') AS accessories
FROM products
`);
const filament = filamentStats.rows[0];
const product = productStats.rows[0];
res.json({
filaments: {
total_skus: parseInt(filament.total_skus),
total_units: parseInt(filament.total_units),
total_refills: parseInt(filament.total_refills),
total_spools: parseInt(filament.total_spools),
out_of_stock_skus: parseInt(filament.out_of_stock_skus),
total_inventory_value: parseInt(filament.total_inventory_value)
},
products: {
total_skus: parseInt(product.total_skus),
total_units: parseInt(product.total_units),
out_of_stock_skus: parseInt(product.out_of_stock_skus),
total_inventory_value: parseInt(product.total_inventory_value),
by_category: {
printers: parseInt(product.printers),
build_plates: parseInt(product.build_plates),
nozzles: parseInt(product.nozzles),
spare_parts: parseInt(product.spare_parts),
accessories: parseInt(product.accessories)
}
},
combined: {
total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus),
total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value),
out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus)
}
});
} catch (error) {
console.error('Error fetching inventory analytics:', error);
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
}
});
// Active sales overview (auth required)
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
try {
const filamentSales = await pool.query(`
SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date,
ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price
FROM filaments
WHERE sale_active = true
ORDER BY sale_percentage DESC
`);
const productSales = await pool.query(`
SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date,
ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price
FROM products
WHERE sale_active = true
ORDER BY sale_percentage DESC
`);
res.json({
filaments: {
count: filamentSales.rowCount,
items: filamentSales.rows
},
products: {
count: productSales.rowCount,
items: productSales.rows
},
total_active_sales: filamentSales.rowCount + productSales.rowCount
});
} catch (error) {
console.error('Error fetching sales analytics:', error);
res.status(500).json({ error: 'Failed to fetch sales analytics' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Rezervni Delovi (Spare Parts)',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
openGraph: {
title: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
url: 'https://filamenteka.rs/delovi',
},
alternates: {
canonical: 'https://filamenteka.rs/delovi',
},
};
export default function DeloviLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function DeloviPage() {
const category = getCategoryBySlug('delovi')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="delovi" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Rezervni Delovi' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Rezervni Delovi
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalni Bambu Lab rezervni delovi i spare parts. AMS moduli, extruder delovi, kablovi, senzori i drugi komponenti za odrzavanje i popravku vaseg 3D stampaca. Kompatibilni sa svim Bambu Lab serijama - A1, P1 i X1.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Filamenti | PLA, PETG, ABS',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
openGraph: {
title: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
url: 'https://filamenteka.rs/filamenti',
},
alternates: {
canonical: 'https://filamenteka.rs/filamenti',
},
};
export default function FilamentiLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function FilamentiPage() {
const category = getCategoryBySlug('filamenti')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="filamenti" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Filamenti' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Filamenti
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalni Bambu Lab filamenti za 3D stampac. U ponudi imamo PLA, PETG, ABS, TPU i mnoge druge materijale u razlicitim finishima - Basic, Matte, Silk, Sparkle, Translucent, Metal i drugi. Svi filamenti su originalni Bambu Lab proizvodi, neotvoreni i u fabrickom pakovanju. Dostupni kao refill pakovanje ili na spulni.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -4,27 +4,8 @@ import { BackToTop } from '../src/components/BackToTop'
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
export const metadata: Metadata = {
metadataBase: new URL('https://filamenteka.rs'),
title: {
default: 'Bambu Lab Oprema Srbija | Filamenteka',
template: '%s | Filamenteka',
},
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, 3D stampaci, build plate podloge, mlaznice, rezervni delovi i oprema.',
openGraph: {
type: 'website',
locale: 'sr_RS',
siteName: 'Filamenteka',
title: 'Bambu Lab Oprema Srbija | Filamenteka',
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji.',
url: 'https://filamenteka.rs',
},
robots: {
index: true,
follow: true,
},
alternates: {
canonical: 'https://filamenteka.rs',
},
title: 'Filamenteka',
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
}
export default function RootLayout({
@@ -32,40 +13,27 @@ export default function RootLayout({
}: {
children: React.ReactNode
}) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Filamenteka',
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji',
url: 'https://filamenteka.rs',
telephone: '+381631031048',
address: {
'@type': 'PostalAddress',
addressCountry: 'RS',
},
priceRange: 'RSD',
}
return (
<html lang="sr" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
document.documentElement.classList.add('no-transitions');
if (window.location.pathname.startsWith('/upadaj') || window.location.pathname.startsWith('/dashboard')) {
// Apply dark mode immediately for admin pages
if (window.location.pathname.startsWith('/upadaj')) {
document.documentElement.classList.add('dark');
} else {
// For non-admin pages, check localStorage
try {
var darkMode = localStorage.getItem('darkMode');
const darkMode = localStorage.getItem('darkMode');
if (darkMode === 'true') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
}
// Remove no-transitions class after a short delay
window.addEventListener('load', function() {
setTimeout(function() {
document.documentElement.classList.remove('no-transitions');
@@ -75,10 +43,6 @@ export default function RootLayout({
`,
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body suppressHydrationWarning>
{children}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne)',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
openGraph: {
title: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
url: 'https://filamenteka.rs/mlaznice',
},
alternates: {
canonical: 'https://filamenteka.rs/mlaznice',
},
};
export default function MlaznicaLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function MlaznicePage() {
const category = getCategoryBySlug('mlaznice')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="mlaznice" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Mlaznice i Hotend' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Mlaznice i Hotend
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab nozzle mlaznice i hotend moduli. Hardened steel, stainless steel i obicne mlaznice u razlicitim precnicima. Complete hotend zamene za A1, P1 i X1 serije. Originalni Bambu Lab delovi u privatnoj prodaji.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Oprema i Dodaci (Accessories)',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
openGraph: {
title: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
url: 'https://filamenteka.rs/oprema',
},
alternates: {
canonical: 'https://filamenteka.rs/oprema',
},
};
export default function OpremaLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function OpremaPage() {
const category = getCategoryBySlug('oprema')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="oprema" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Oprema i Dodaci' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Oprema i Dodaci
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab oprema i dodaci za 3D stampace. AMS (Automatic Material System), spool holderi, alati za odrzavanje, filament drajeri i druga dodatna oprema. Sve sto vam treba za optimalan rad vaseg Bambu Lab 3D stampaca.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,384 +1,277 @@
'use client'
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { SaleCountdown } from '@/src/components/SaleCountdown';
import ColorRequestModal from '@/src/components/ColorRequestModal';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
import { getEnabledCategories } from '@/src/config/categories';
import { filamentService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { trackEvent } from '@/src/components/MatomoAnalytics';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
import { SaleCountdown } from '../src/components/SaleCountdown';
import TabbedNavigation from '../src/components/TabbedNavigation';
import PrintersTable from '../src/components/PrintersTable';
import GearTable from '../src/components/GearTable';
import ColorRequestSection from '../src/components/ColorRequestSection';
import { Filament } from '../src/types/filament';
import { filamentService } from '../src/services/api';
import { trackEvent } from '../src/components/MatomoAnalytics';
export default function Home() {
const [filaments, setFilaments] = useState<Filament[]>([]);
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [activeTab, setActiveTab] = useState('filaments');
// Removed V1/V2 toggle - now only using V2
const categories = getEnabledCategories();
useEffect(() => { setMounted(true); }, []);
// Initialize dark mode from localStorage after mounting
useEffect(() => {
const fetchFilaments = async () => {
try {
const data = await filamentService.getAll();
setFilaments(data);
} catch (err) {
console.error('Failed to fetch filaments for sale check:', err);
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved) {
setDarkMode(JSON.parse(saved));
}
};
fetchFilaments();
}, []);
const activeSaleFilaments = filaments.filter(f => f.sale_active === true);
const hasActiveSale = activeSaleFilaments.length > 0;
const maxSalePercentage = hasActiveSale
? Math.max(...activeSaleFilaments.map(f => f.sale_percentage || 0), 0)
: 0;
const saleEndDate = (() => {
const withEndDate = activeSaleFilaments.filter(f => f.sale_end_date);
if (withEndDate.length === 0) return null;
return withEndDate.reduce((latest, current) => {
if (!latest.sale_end_date) return current;
if (!current.sale_end_date) return latest;
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
}).sale_end_date ?? null;
})();
// Update dark mode
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
const fetchFilaments = async () => {
try {
setLoading(true);
setError(null);
const filamentsData = await filamentService.getAll();
setFilaments(filamentsData);
} catch (err: any) {
console.error('API Error:', err);
// More descriptive error messages
if (err.code === 'ERR_NETWORK') {
setError('Network Error - Unable to connect to API');
} else if (err.response) {
setError(`Server error: ${err.response.status} - ${err.response.statusText}`);
} else if (err.request) {
setError('No response from server - check if API is running');
} else {
setError(err.message || 'Greška pri učitavanju filamenata');
}
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFilaments();
// Auto-refresh data every 30 seconds to stay in sync
const interval = setInterval(() => {
fetchFilaments();
}, 30000);
// Also refresh when window regains focus
const handleFocus = () => {
fetchFilaments();
};
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
};
}, []);
const handleLogoClick = () => {
setResetKey(prev => prev + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
{/* ═══ HERO ═══ */}
<section className="relative overflow-hidden noise-overlay">
{/* Rich multi-layer gradient background */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-950 via-purple-950 to-indigo-950" />
{/* Floating color orbs — big, bright, energetic */}
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute w-[600px] h-[600px] -top-48 -left-48 rounded-full opacity-40 blur-[100px] animate-float"
style={{ background: 'radial-gradient(circle, #3b82f6, transparent)' }} />
<div className="absolute w-[500px] h-[500px] top-10 right-0 rounded-full opacity-40 blur-[80px] animate-float-slow"
style={{ background: 'radial-gradient(circle, #a855f7, transparent)' }} />
<div className="absolute w-[450px] h-[450px] -bottom-24 left-1/3 rounded-full opacity-40 blur-[90px] animate-float-delayed"
style={{ background: 'radial-gradient(circle, #f59e0b, transparent)' }} />
<div className="absolute w-[400px] h-[400px] bottom-10 right-1/4 rounded-full opacity-40 blur-[80px] animate-float"
style={{ background: 'radial-gradient(circle, #22c55e, transparent)' }} />
<div className="absolute w-[350px] h-[350px] top-1/2 left-10 rounded-full opacity-35 blur-[70px] animate-float-slow"
style={{ background: 'radial-gradient(circle, #ef4444, transparent)' }} />
<div className="absolute w-[300px] h-[300px] top-1/4 right-1/3 rounded-full opacity-35 blur-[75px] animate-float-delayed"
style={{ background: 'radial-gradient(circle, #06b6d4, transparent)' }} />
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
<div className="flex-1 flex flex-col sm:flex-row justify-center items-center gap-1 sm:gap-3 text-sm sm:text-lg">
<span className="text-blue-700 dark:text-blue-300 font-medium animate-pulse text-center">
Kupovina po gramu dostupna
</span>
<span className="hidden sm:inline text-gray-400 dark:text-gray-600"></span>
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse text-center">
Popust za 5+ komada
</span>
</div>
{/* Grid pattern overlay */}
<div className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
backgroundSize: '60px 60px'
}} />
<div className="flex-shrink-0">
{mounted ? (
<button
onClick={() => {
setDarkMode(!darkMode);
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
}}
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
>
{darkMode ? '☀️' : '🌙'}
</button>
) : (
<div className="w-10 h-10" />
)}
</div>
</div>
</div>
</header>
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-28 lg:py-32">
<div className="flex flex-col items-center text-center reveal-up">
{/* Logo */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Logo centered above content */}
<div className="flex justify-center mb-8">
<button
onClick={handleLogoClick}
className="group transition-transform duration-200"
title="Klikni za reset"
>
{/* Using next/image would cause preload, so we use regular img with loading="lazy" */}
<img
src="/logo.png"
alt="Filamenteka"
loading="eager"
loading="lazy"
decoding="async"
className="h-24 sm:h-32 md:h-40 w-auto drop-shadow-2xl mb-8"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
{/* Tagline */}
<h1
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-white mb-5 tracking-tight leading-[1.1]"
style={{ fontFamily: 'var(--font-display)' }}
>
Bambu Lab oprema
<br />
<span className="hero-gradient-text">
u Srbiji
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-300 max-w-xl mb-10 leading-relaxed reveal-up reveal-delay-1">
Filamenti, stampaci, podloge, mlaznice, delovi i oprema.
<br className="hidden sm:block" />
Originalni proizvodi, privatna prodaja.
</p>
{/* CTA */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 reveal-up reveal-delay-2">
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Hero CTA')}
className="group inline-flex items-center justify-center gap-2.5 px-8 py-4
bg-blue-500 hover:bg-blue-600 text-white font-bold text-base rounded-2xl
shadow-lg shadow-blue-500/25
hover:shadow-2xl hover:shadow-blue-500/35
transition-all duration-300 active:scale-[0.97]"
style={{ fontFamily: 'var(--font-display)' }}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
Kupi na KupujemProdajem
<svg className="w-4 h-4 opacity-60 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</a>
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Hero CTA')}
className="inline-flex items-center justify-center gap-2.5 px-8 py-4
text-white/90 hover:text-white font-semibold text-base rounded-2xl
border border-white/20 hover:border-white/40
hover:bg-white/[0.08]
transition-all duration-300 active:scale-[0.97]"
style={{ fontFamily: 'var(--font-display)' }}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
+381 63 103 1048
</a>
</div>
</div>
</div>
{/* Bottom fade */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[var(--surface-primary)] to-transparent z-10" />
</section>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-8 relative z-20">
{/* Sale Banner */}
<SaleCountdown
hasActiveSale={hasActiveSale}
maxSalePercentage={maxSalePercentage}
saleEndDate={saleEndDate}
/>
{/* ═══ CATEGORIES ═══ */}
<section className="py-12 sm:py-16">
<div className="text-center mb-10 sm:mb-14">
<h2
className="text-4xl sm:text-5xl font-extrabold tracking-tight mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Sta nudimo
</h2>
<p style={{ color: 'var(--text-secondary)' }} className="text-base sm:text-lg max-w-md mx-auto">
Kompletna ponuda originalne Bambu Lab opreme
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 auto-rows-auto">
{categories.map((cat, i) => {
// First card (filamenti) is featured — spans 2 cols on lg
const isFeatured = i === 0;
// Last card spans 2 cols on sm/lg for variety
const isWide = i === categories.length - 1;
return (
<Link
key={cat.slug}
href={`/${cat.slug}`}
onClick={() => trackEvent('Navigation', 'Category Click', cat.label)}
className={`category-card group block rounded-2xl reveal-up reveal-delay-${i + 1} ${
isFeatured ? 'lg:col-span-2 lg:row-span-2' : ''
} ${isWide ? 'sm:col-span-2 lg:col-span-1' : ''}`}
style={{
background: `linear-gradient(135deg, ${cat.colorHex}, ${cat.colorHex}cc)`,
boxShadow: `0 8px 32px -8px ${cat.colorHex}35`,
className="h-36 sm:h-44 md:h-52 w-auto drop-shadow-lg group-hover:drop-shadow-2xl transition-all duration-200"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
>
<div className={`relative z-10 ${isFeatured ? 'p-8 sm:p-10' : 'p-6 sm:p-7'}`}>
{/* SVG Icon */}
<div className={`${isFeatured ? 'w-14 h-14' : 'w-11 h-11'} bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center mb-4`}>
<CategoryIcon slug={cat.slug} className={isFeatured ? 'w-7 h-7 text-white' : 'w-5 h-5 text-white'} />
</div>
<h3 className={`${isFeatured ? 'text-2xl lg:text-3xl' : 'text-lg'} font-bold text-white tracking-tight mb-2`}
style={{ fontFamily: 'var(--font-display)' }}>
{cat.label}
</h3>
<p className={`text-white/75 ${isFeatured ? 'text-base' : 'text-sm'} leading-relaxed mb-5`}>
{cat.description}
</p>
<div className="flex items-center text-sm font-bold text-white/90 group-hover:text-white transition-colors">
Pogledaj ponudu
<svg className="w-4 h-4 ml-1.5 group-hover:translate-x-1.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</div>
</div>
</Link>
);
})}
</div>
</section>
{/* ═══ INFO CARDS ═══ */}
<section className="pb-12 sm:pb-16">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-5">
{/* Reusable Spool Price */}
<div
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#3b82f6', background: 'var(--surface-elevated)' }}
>
<div className="flex-shrink-0 w-11 h-11 bg-blue-500/10 dark:bg-blue-500/15 rounded-xl flex items-center justify-center">
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
<div>
<h3 className="font-extrabold text-base mb-1" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
Visekratna spulna
</h3>
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Cena visekratne spulne: <span className="font-extrabold text-blue-500">499 RSD</span>
</p>
</div>
</div>
{/* Selling by Grams */}
<div
className="rounded-2xl border border-l-4 p-6 sm:p-7 flex items-start gap-4 shadow-sm"
style={{ borderColor: 'var(--border-subtle)', borderLeftColor: '#10b981', background: 'var(--surface-elevated)' }}
>
<div className="flex-shrink-0 w-11 h-11 bg-emerald-500/10 dark:bg-emerald-500/15 rounded-xl flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z" />
</svg>
</div>
<div>
<h3 className="font-extrabold text-base mb-1.5" style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}>
Prodaja na grame
</h3>
<p className="text-sm font-medium mb-2.5" style={{ color: 'var(--text-secondary)' }}>
Idealno za testiranje materijala ili manje projekte.
</p>
<div className="flex flex-wrap gap-2">
{[
{ g: '50g', price: '299' },
{ g: '100g', price: '499' },
{ g: '200g', price: '949' },
].map(({ g, price }) => (
<span key={g} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold bg-emerald-500/10 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
{g} {price} RSD
</span>
))}
</div>
</div>
</div>
</div>
</section>
{/* ═══ DISCLAIMER ═══ */}
<section className="pb-12 sm:pb-16">
<div className="rounded-2xl border border-amber-200/60 dark:border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/[0.06] p-5 sm:p-6">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 bg-amber-400/20 dark:bg-amber-500/15 rounded-lg flex items-center justify-center mt-0.5">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<p className="text-sm leading-relaxed text-amber-900 dark:text-amber-200/80">
Ovo je privatna prodaja fizickog lica. Filamenteka nije registrovana prodavnica.
Sva kupovina se odvija preko KupujemProdajem platforme.
</p>
</div>
</div>
</section>
{/* ═══ CONTACT CTA ═══ */}
<section className="pb-16 sm:pb-20">
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-slate-100 via-slate-50 to-blue-50 dark:bg-none dark:from-transparent dark:via-transparent dark:to-transparent dark:bg-[#0f172a] dark:noise-overlay border border-slate-200/80 dark:border-transparent">
<div className="relative z-10 p-8 sm:p-12 lg:p-14 text-center">
<h2
className="text-2xl sm:text-3xl lg:text-4xl font-extrabold mb-3 tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Zainteresovani?
</h2>
<p className="mb-8 max-w-md mx-auto text-base" style={{ color: 'var(--text-muted)' }}>
Pogledajte ponudu na KupujemProdajem ili nas kontaktirajte direktno.
</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-3 sm:gap-4">
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Contact CTA')}
className="group inline-flex items-center gap-2.5 px-7 py-3.5
bg-blue-500 hover:bg-blue-600 text-white
font-bold rounded-xl
shadow-lg shadow-blue-500/25
hover:shadow-xl hover:shadow-blue-500/35
transition-all duration-300 active:scale-[0.97]"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
Kupi na KupujemProdajem
<svg className="w-4 h-4 opacity-50 group-hover:opacity-100 group-hover:translate-x-0.5 transition-all" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</a>
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Contact CTA')}
className="inline-flex items-center gap-2.5 px-7 py-3.5
font-semibold rounded-xl
text-slate-700 dark:text-white/90 hover:text-slate-900 dark:hover:text-white
border border-slate-300 dark:border-white/25 hover:border-slate-400 dark:hover:border-white/50
hover:bg-slate-50 dark:hover:bg-white/[0.1]
transition-all duration-300 active:scale-[0.97]"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
+381 63 103 1048
</a>
</div>
{/* Color Request */}
<div className="mt-6">
<button
onClick={() => {
setShowColorRequestModal(true);
trackEvent('Navigation', 'Request Color', 'Contact CTA');
}}
className="inline-flex items-center gap-2 text-sm font-medium transition-colors duration-200 text-slate-500 hover:text-slate-800 dark:text-white/70 dark:hover:text-white"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
</svg>
Ne nalazite boju? Zatrazite novu
/>
</button>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-8">
<a
href="https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Homepage')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Kupi na Kupujem Prodajem
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="tel:+381677102845"
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Pozovi +381 67 710 2845
</a>
</div>
{/* Tabs Navigation */}
<div className="mb-8">
<TabbedNavigation
tabs={[
{
id: 'filaments',
label: 'Filamenti',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)
},
{
id: 'printers',
label: 'Štampači',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
)
},
{
id: 'gear',
label: 'Oprema',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
]}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</div>
</section>
{/* Tab Content */}
{activeTab === 'filaments' && (
<>
<SaleCountdown
hasActiveSale={filaments.some(f => f.sale_active === true)}
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
saleEndDate={(() => {
const activeSales = filaments.filter(f => f.sale_active === true && f.sale_end_date);
if (activeSales.length === 0) return null;
const latestSale = activeSales.reduce((latest, current) => {
if (!latest.sale_end_date) return current;
if (!current.sale_end_date) return latest;
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
}).sale_end_date;
return latestSale;
})()}
/>
<FilamentTableV2
key={resetKey}
filaments={filaments}
/>
{/* Color Request Section Below Table */}
<div className="mt-16">
<ColorRequestSection />
</div>
</>
)}
{activeTab === 'printers' && <PrintersTable />}
{activeTab === 'gear' && <GearTable />}
</main>
<SiteFooter />
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
<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">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Kontakt</h3>
<a
href="tel:+381677102845"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
+381 67 710 2845
</a>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab Build Plate (Podloga)',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
openGraph: {
title: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
url: 'https://filamenteka.rs/ploce',
},
alternates: {
canonical: 'https://filamenteka.rs/ploce',
},
};
export default function PloceLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function PlocePage() {
const category = getCategoryBySlug('ploce')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="ploce" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Build Plate (Podloge)' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab Build Plate Podloge
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Originalne Bambu Lab build plate podloge za 3D stampanje. Cool Plate, Engineering Plate, High Temp Plate i Textured PEI Plate. Kompatibilne sa A1 Mini, A1, P1S, X1C i drugim modelima. Nove i polovne podloge u privatnoj prodaji.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,205 +0,0 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
export const metadata: Metadata = {
title: 'Politika Privatnosti',
description: 'Politika privatnosti sajta Filamenteka — informacije o prikupljanju i obradi podataka, kolacicima, analitici i pravima korisnika.',
alternates: {
canonical: 'https://filamenteka.rs/politika-privatnosti',
},
};
export default function PolitikaPrivatnostiPage() {
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Politika Privatnosti' },
]} />
<article className="mt-6">
<h1
className="text-3xl sm:text-4xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Politika Privatnosti
</h1>
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
Poslednje azuriranje: Februar 2026
</p>
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
1. Uvod
</h2>
<p>
Filamenteka (filamenteka.rs) postuje vasu privatnost. Ova politika privatnosti
objasnjava koje podatke prikupljamo, kako ih koristimo i koja prava imate u vezi
sa vasim podacima.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
2. Podaci koje prikupljamo
</h2>
<h3
className="text-lg font-semibold mt-4 mb-2"
style={{ color: 'var(--text-primary)' }}
>
2.1 Analitika (Matomo)
</h3>
<p className="mb-3">
Koristimo Matomo, platformu za web analitiku otvorenog koda, koja je hostovana na
nasem sopstvenom serveru. Matomo prikuplja sledece anonimizovane podatke:
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Anonimizovana IP adresa (poslednja dva okteta su maskirana)</li>
<li>Tip uredjaja i operativni sistem</li>
<li>Pregledac i rezolucija ekrana</li>
<li>Posecene stranice i vreme posete</li>
<li>Referalna stranica (odakle ste dosli)</li>
</ul>
<p className="mt-3">
Ovi podaci se koriste iskljucivo za razumevanje kako posetioci koriste sajt i za
poboljsanje korisnickog iskustva. Podaci se ne dele sa trecim stranama.
</p>
<h3
className="text-lg font-semibold mt-6 mb-2"
style={{ color: 'var(--text-primary)' }}
>
2.2 Podaci iz kontakt forme (Zahtev za boju)
</h3>
<p>
Kada podnesete zahtev za novu boju filamenta, prikupljamo sledece podatke koje
dobrovoljno unosite:
</p>
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
<li>Vase ime</li>
<li>Broj telefona</li>
<li>Zeljena boja filamenta i poruka</li>
</ul>
<p className="mt-3">
Ovi podaci se koriste iskljucivo za obradu vaseg zahteva i kontaktiranje u vezi
sa dostupnoscu trazene boje. Ne koristimo ih u marketinske svrhe niti ih delimo
sa trecim stranama.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
3. Kolacici
</h2>
<p>
Filamenteka ne koristi kolacice za pracenje korisnika niti za marketinske svrhe.
Matomo analitika je konfigurisana tako da radi bez kolacica za pracenje. Jedini
lokalni podaci koji se cuvaju u vasem pregledacu su podesavanja interfejsa (npr.
tamni rezim), koja se cuvaju u localStorage i nikada se ne salju na server.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
4. Korisnicki nalozi
</h2>
<p>
Sajt ne nudi mogucnost registracije niti kreiranja korisnickih naloga za
posetioce. Ne prikupljamo niti cuvamo lozinke, email adrese ili druge podatke
za autentifikaciju posetilaca.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
5. Odjava od analitike (Matomo Opt-Out)
</h2>
<p>
Ako zelite da budete iskljuceni iz Matomo analitike, mozete aktivirati &quot;Do Not
Track&quot; opciju u vasem pregledacu. Matomo je konfigurisan da postuje ovo
podesavanje i nece prikupljati podatke o vasim posetama.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
6. GDPR uskladjenost
</h2>
<p>
U skladu sa Opstom uredbom o zastiti podataka (GDPR) i Zakonom o zastiti podataka
o licnosti Republike Srbije, imate sledeca prava:
</p>
<ul className="list-disc list-inside space-y-1 ml-2 mt-2">
<li>Pravo na pristup vasim podacima</li>
<li>Pravo na ispravku netacnih podataka</li>
<li>Pravo na brisanje podataka (&quot;pravo na zaborav&quot;)</li>
<li>Pravo na ogranicenje obrade</li>
<li>Pravo na prigovor na obradu podataka</li>
</ul>
<p className="mt-3">
Za ostvarivanje bilo kog od ovih prava, kontaktirajte nas putem telefona
navedenog na sajtu.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
7. Rukovalac podacima
</h2>
<p>
Rukovalac podacima je Filamenteka, privatna prodaja fizickog lica.
</p>
<ul className="list-none space-y-1 mt-2">
<li>Sajt: filamenteka.rs</li>
<li>Telefon: +381 63 103 1048</li>
</ul>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
8. Izmene politike privatnosti
</h2>
<p>
Zadrzavamo pravo da azuriramo ovu politiku privatnosti u bilo kom trenutku.
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
izmene.
</p>
</section>
</div>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
openGraph: {
title: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
url: 'https://filamenteka.rs/stampaci',
},
alternates: {
canonical: 'https://filamenteka.rs/stampaci',
},
};
export default function StampaciLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,43 +0,0 @@
'use client';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
import { CatalogPage } from '@/src/components/catalog/CatalogPage';
import { getCategoryBySlug } from '@/src/config/categories';
export default function StampaciPage() {
const category = getCategoryBySlug('stampaci')!;
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader currentCategory="stampaci" />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<article>
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: '3D Stampaci' },
]} />
<div className="flex items-center gap-3 mt-3 mb-6">
<div className="w-1.5 h-8 rounded-full" style={{ backgroundColor: category.colorHex }} />
<h1
className="text-2xl sm:text-3xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Bambu Lab 3D Stampaci
</h1>
</div>
<CatalogPage
category={category}
seoContent={
<p className="text-sm leading-relaxed max-w-3xl" style={{ color: 'var(--text-secondary)' }}>
Bambu Lab 3D stampaci u privatnoj prodaji. U ponudi imamo A1 Mini, A1, P1S, X1C i druge modele. Novi i polovni stampaci u razlicitim stanjima. Svi stampaci su originalni Bambu Lab proizvodi sa svom originalnom opremom.
</p>
}
/>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -4,7 +4,6 @@ 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 {
@@ -54,16 +53,13 @@ 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)) {
window.location.href = '/upadaj';
router.push('/upadaj');
}
}, [mounted]);
}, [router]);
// Fetch colors
const fetchColors = async () => {
@@ -180,22 +176,22 @@ export default function ColorsManagement() {
};
if (loading) {
return <div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
<div className="text-gray-600 dark:text-white/40">Učitavanje...</div>
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
</div>;
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] transition-colors">
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
<div className="flex">
{/* Sidebar */}
<div className="w-64 bg-white dark:bg-white/[0.04] shadow-lg h-screen">
<div className="w-64 bg-white dark:bg-gray-800 shadow-lg h-screen">
<div className="p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
<nav className="space-y-2">
<a
href="/dashboard"
className="block px-4 py-2 text-gray-700 dark:text-white/60 hover:bg-gray-100 dark:hover:bg-white/[0.06] rounded"
href="/upadaj/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
</a>
@@ -211,7 +207,7 @@ export default function ColorsManagement() {
{/* Main Content */}
<div className="flex-1">
<header className="bg-white dark:bg-white/[0.04] shadow transition-colors">
<header className="bg-white dark:bg-gray-800 shadow transition-colors">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
@@ -222,7 +218,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 flex-wrap">
<div className="flex gap-4">
{!showAddForm && !editingColor && (
<button
onClick={() => setShowAddForm(true)}
@@ -231,7 +227,6 @@ export default function ColorsManagement() {
Dodaj novu boju
</button>
)}
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
{selectedColors.size > 0 && (
<button
onClick={handleBulkDelete}
@@ -249,7 +244,7 @@ export default function ColorsManagement() {
{mounted && (
<button
onClick={() => setDarkMode(!darkMode)}
className="px-4 py-2 bg-gray-200 dark:bg-white/[0.06] text-gray-800 dark:text-white/70 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
>
{darkMode ? '☀️' : '🌙'}
@@ -292,7 +287,7 @@ export default function ColorsManagement() {
placeholder="Pretraži boje..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-white/60 bg-white dark:bg-white/[0.04] border border-gray-300 dark:border-white/[0.08] rounded-lg focus:outline-none focus:border-blue-500"
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -301,9 +296,9 @@ export default function ColorsManagement() {
</div>
{/* Colors Table */}
<div className="overflow-x-auto bg-white dark:bg-white/[0.04] rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.06]">
<thead className="bg-gray-50 dark:bg-white/[0.06]">
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left">
<input
@@ -316,36 +311,36 @@ export default function ColorsManagement() {
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
})()}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Akcije</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((color) => (
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.06]">
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedColors.has(color.id)}
onChange={() => toggleColorSelection(color.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-white/[0.08]"
className="w-10 h-10 rounded border-2 border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
/>
</td>
@@ -389,7 +384,7 @@ export default function ColorsManagement() {
{/* Edit Modal */}
{editingColor && (
<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-white/[0.04] rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
<ColorForm
@@ -454,7 +449,7 @@ function ColorForm({
};
return (
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-white/[0.04] rounded-lg shadow transition-colors"}>
<div className={isModal ? "" : "mb-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors"}>
{!isModal && (
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
@@ -462,7 +457,7 @@ function ColorForm({
)}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Naziv boje</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Naziv boje</label>
<input
type="text"
name="name"
@@ -470,12 +465,12 @@ function ColorForm({
onChange={handleChange}
required
placeholder="npr. Crvena"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Hex kod boje</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Hex kod boje</label>
<div className="flex gap-2 items-center">
<input
type="color"
@@ -483,7 +478,7 @@ function ColorForm({
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
disabled={isBambuLabColor}
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-gray-600 rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
/>
<input
@@ -495,18 +490,18 @@ function ColorForm({
readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000"
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-gray-100 dark:bg-[#060a14] cursor-not-allowed' : ''}`}
className={`flex-1 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 ${isBambuLabColor ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' : ''}`}
/>
</div>
{isBambuLabColor && (
<p className="text-xs text-gray-500 dark:text-white/40 mt-1">
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Bambu Lab predefinisana boja - hex kod se ne može menjati
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Refil</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Refil</label>
<input
type="number"
name="cena_refill"
@@ -516,12 +511,12 @@ function ColorForm({
min="0"
step="1"
placeholder="3499"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-white/60">Cena Spulna</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Cena Spulna</label>
<input
type="number"
name="cena_spulna"
@@ -531,7 +526,7 @@ function ColorForm({
min="0"
step="1"
placeholder="3999"
className="w-full px-3 py-2 border border-gray-300 dark:border-white/[0.08] rounded-md bg-white dark:bg-white/[0.06] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="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"
/>
</div>
@@ -539,7 +534,7 @@ function ColorForm({
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white/70 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
Otkaži
</button>

View File

@@ -1,13 +0,0 @@
export function generateStaticParams() {
return [
{ category: 'stampaci' },
{ category: 'ploce' },
{ category: 'mlaznice' },
{ category: 'delovi' },
{ category: 'oprema' },
];
}
export default function CategoryLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -1,577 +0,0 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useParams, notFound } from 'next/navigation';
import { productService, printerModelService } from '@/src/services/api';
import { Product, ProductCategory, ProductCondition, PrinterModel } from '@/src/types/product';
import '@/src/styles/select.css';
const CATEGORY_MAP: Record<string, { category: ProductCategory; label: string; plural: string }> = {
stampaci: { category: 'printer', label: 'Stampac', plural: 'Stampaci' },
ploce: { category: 'build_plate', label: 'Ploca', plural: 'Ploce' },
mlaznice: { category: 'nozzle', label: 'Mlaznica', plural: 'Mlaznice' },
delovi: { category: 'spare_part', label: 'Deo', plural: 'Delovi' },
oprema: { category: 'accessory', label: 'Oprema', plural: 'Oprema' },
};
const CONDITION_LABELS: Record<string, string> = {
new: 'Novo',
used_like_new: 'Korisceno - kao novo',
used_good: 'Korisceno - dobro',
used_fair: 'Korisceno - pristojno',
};
// Categories that support printer compatibility
const PRINTER_COMPAT_CATEGORIES: ProductCategory[] = ['build_plate', 'nozzle', 'spare_part'];
export default function CategoryPage() {
const params = useParams();
const slug = params.category as string;
const categoryConfig = CATEGORY_MAP[slug];
// If slug is not recognized, show not found
if (!categoryConfig) {
notFound();
}
const { category, label, plural } = categoryConfig;
const [products, setProducts] = useState<Product[]>([]);
const [printerModels, setPrinterModels] = useState<PrinterModel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<string>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedProducts, setSelectedProducts] = useState<Set<string>>(new Set());
const fetchProducts = async () => {
try {
setLoading(true);
const [data, models] = await Promise.all([
productService.getAll({ category }),
PRINTER_COMPAT_CATEGORIES.includes(category)
? printerModelService.getAll().catch(() => [])
: Promise.resolve([]),
]);
setProducts(data);
setPrinterModels(models);
} catch (err) {
setError('Greska pri ucitavanju proizvoda');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, [category]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const filteredAndSorted = useMemo(() => {
let filtered = products;
if (searchTerm) {
const search = searchTerm.toLowerCase();
filtered = products.filter(p =>
p.name.toLowerCase().includes(search) ||
p.description?.toLowerCase().includes(search)
);
}
return [...filtered].sort((a, b) => {
let aVal: any = a[sortField as keyof Product] || '';
let bVal: any = b[sortField as keyof Product] || '';
if (sortField === 'price' || sortField === 'stock') {
aVal = Number(aVal) || 0;
bVal = Number(bVal) || 0;
return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
}
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [products, searchTerm, sortField, sortOrder]);
const handleSave = async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
try {
const dataToSave = {
...product,
category,
};
if (product.id) {
await productService.update(product.id, dataToSave);
} else {
await productService.create(dataToSave);
}
setEditingProduct(null);
setShowAddForm(false);
fetchProducts();
} catch (err: any) {
const msg = err.response?.data?.error || err.message || 'Greska pri cuvanju proizvoda';
setError(msg);
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovaj proizvod?')) return;
try {
await productService.delete(id);
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedProducts.size === 0) return;
if (!confirm(`Obrisati ${selectedProducts.size} proizvoda?`)) return;
try {
await Promise.all(Array.from(selectedProducts).map(id => productService.delete(id)));
setSelectedProducts(new Set());
fetchProducts();
} catch (err) {
setError('Greska pri brisanju proizvoda');
console.error('Bulk delete error:', err);
}
};
const toggleSelection = (id: string) => {
const next = new Set(selectedProducts);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedProducts(next);
};
const toggleSelectAll = () => {
if (selectedProducts.size === filteredAndSorted.length) {
setSelectedProducts(new Set());
} else {
setSelectedProducts(new Set(filteredAndSorted.map(p => p.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">{plural}</h1>
<p className="text-white/40 mt-1">{products.length} proizvoda ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingProduct && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
>
Dodaj {label.toLowerCase()}
</button>
)}
{selectedProducts.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
>
Obrisi izabrane ({selectedProducts.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi proizvode..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Add/Edit Form */}
{(showAddForm || editingProduct) && (
<ProductForm
product={editingProduct || undefined}
printerModels={printerModels}
showPrinterCompat={PRINTER_COMPAT_CATEGORIES.includes(category)}
onSave={handleSave}
onCancel={() => {
setEditingProduct(null);
setShowAddForm(false);
}}
/>
)}
{/* Products Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSorted.length > 0 && selectedProducts.size === filteredAndSorted.length}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</th>
<th onClick={() => handleSort('name')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Naziv {sortField === 'name' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('condition')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Stanje {sortField === 'condition' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('price')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Cena {sortField === 'price' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('stock')} className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase cursor-pointer hover:bg-white/[0.08]">
Kolicina {sortField === 'stock' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Popust</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/60 uppercase">Akcije</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{filteredAndSorted.map(product => (
<tr key={product.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedProducts.has(product.id)}
onChange={() => toggleSelection(product.id)}
className="w-4 h-4 text-blue-600 bg-white/[0.06] border-white/[0.08] rounded"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{product.image_url && (
<img src={product.image_url} alt={product.name} className="w-10 h-10 rounded object-cover" />
)}
<div>
<div className="text-sm font-medium text-white/90">{product.name}</div>
{product.description && (
<div className="text-xs text-white/40 truncate max-w-xs">{product.description}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-white/60">
{CONDITION_LABELS[product.condition] || product.condition}
</td>
<td className="px-4 py-3 text-sm font-bold text-white/90">
{product.price.toLocaleString('sr-RS')} RSD
</td>
<td className="px-4 py-3 text-sm">
{product.stock > 2 ? (
<span className="text-green-400 font-bold">{product.stock}</span>
) : product.stock > 0 ? (
<span className="text-yellow-400 font-bold">{product.stock}</span>
) : (
<span className="text-red-400 font-bold">0</span>
)}
</td>
<td className="px-4 py-3 text-sm">
{product.sale_active && product.sale_percentage ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{product.sale_percentage}%
</span>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => {
setEditingProduct(product);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(product.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
{filteredAndSorted.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-white/40">
Nema proizvoda u ovoj kategoriji
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// Product Form Component
function ProductForm({
product,
printerModels,
showPrinterCompat,
onSave,
onCancel,
}: {
product?: Product;
printerModels: PrinterModel[];
showPrinterCompat: boolean;
onSave: (data: Partial<Product> & { printer_model_ids?: string[] }) => void;
onCancel: () => void;
}) {
const [formData, setFormData] = useState({
name: product?.name || '',
description: product?.description || '',
price: product?.price || 0,
condition: (product?.condition || 'new') as ProductCondition,
stock: product?.stock || 0,
image_url: product?.image_url || '',
attributes: JSON.stringify(product?.attributes || {}, null, 2),
});
const [selectedPrinterIds, setSelectedPrinterIds] = useState<string[]>([]);
// Load compatible printers on mount
useEffect(() => {
if (product?.compatible_printers) {
// Map names to IDs
const ids = printerModels
.filter(m => product.compatible_printers?.includes(m.name))
.map(m => m.id);
setSelectedPrinterIds(ids);
}
}, [product, printerModels]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? (parseFloat(value) || 0) : value,
}));
};
const togglePrinter = (id: string) => {
setSelectedPrinterIds(prev =>
prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('Naziv je obavezan');
return;
}
let attrs = {};
try {
attrs = formData.attributes.trim() ? JSON.parse(formData.attributes) : {};
} catch {
alert('Atributi moraju biti validan JSON');
return;
}
onSave({
id: product?.id,
name: formData.name,
description: formData.description || undefined,
price: formData.price,
condition: formData.condition,
stock: formData.stock,
image_url: formData.image_url || undefined,
attributes: attrs,
printer_model_ids: showPrinterCompat ? selectedPrinterIds : undefined,
});
};
return (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{product ? 'Izmeni proizvod' : 'Dodaj proizvod'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Naziv</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Opis</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena (RSD)</label>
<input
type="number"
name="price"
value={formData.price}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Stanje</label>
<select
name="condition"
value={formData.condition}
onChange={handleChange}
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="new">Novo</option>
<option value="used_like_new">Korisceno - kao novo</option>
<option value="used_good">Korisceno - dobro</option>
<option value="used_fair">Korisceno - pristojno</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Kolicina</label>
<input
type="number"
name="stock"
value={formData.stock}
onChange={handleChange}
min="0"
required
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">URL slike</label>
<input
type="url"
name="image_url"
value={formData.image_url}
onChange={handleChange}
placeholder="https://..."
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1 text-white/60">Atributi (JSON)</label>
<textarea
name="attributes"
value={formData.attributes}
onChange={handleChange}
rows={3}
placeholder='{"key": "value"}'
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Printer Compatibility */}
{showPrinterCompat && printerModels.length > 0 && (
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-2 text-white/60">Kompatibilni stampaci</label>
<div className="flex flex-wrap gap-2">
{printerModels.map(model => (
<button
key={model.id}
type="button"
onClick={() => togglePrinter(model.id)}
className={`px-3 py-1 rounded text-sm transition-colors ${
selectedPrinterIds.includes(model.id)
? 'bg-blue-600 text-white'
: 'bg-white/[0.06] text-white/60 hover:bg-white/[0.08]'
}`}
>
{model.name}
</button>
))}
</div>
</div>
)}
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,460 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { analyticsService, filamentService, productService } from '@/src/services/api';
import { InventoryStats, SalesStats, Product } from '@/src/types/product';
import { Filament } from '@/src/types/filament';
type Tab = 'inventar' | 'prodaja' | 'posetioci' | 'nabavka';
const CATEGORY_LABELS: Record<string, string> = {
printer: 'Stampaci',
build_plate: 'Ploce',
nozzle: 'Mlaznice',
spare_part: 'Delovi',
accessory: 'Oprema',
};
export default function AnalitikaPage() {
const [activeTab, setActiveTab] = useState<Tab>('inventar');
const [inventoryStats, setInventoryStats] = useState<InventoryStats | null>(null);
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [inv, sales, fils, prods] = await Promise.all([
analyticsService.getInventory().catch(() => null),
analyticsService.getSales().catch(() => null),
filamentService.getAll().catch(() => []),
productService.getAll().catch(() => []),
]);
setInventoryStats(inv);
setSalesStats(sales);
setFilaments(fils);
setProducts(prods);
} catch (error) {
console.error('Error loading analytics:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const tabs: { key: Tab; label: string }[] = [
{ key: 'inventar', label: 'Inventar' },
{ key: 'prodaja', label: 'Prodaja' },
{ key: 'posetioci', label: 'Posetioci' },
{ key: 'nabavka', label: 'Nabavka' },
];
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje analitike...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Analitika</h1>
<p className="text-white/40 mt-1">Pregled stanja inventara i prodaje</p>
</div>
{/* Tabs */}
<div className="flex border-b border-white/[0.06]">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-white/40 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Inventar Tab */}
{activeTab === 'inventar' && (
<div className="space-y-6">
{inventoryStats ? (
<>
{/* Filament stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.filaments.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Refili</p>
<p className="text-2xl font-bold text-green-400">{inventoryStats.filaments.total_refills}</p>
</div>
<div>
<p className="text-sm text-white/40">Spulne</p>
<p className="text-2xl font-bold text-blue-400">{inventoryStats.filaments.total_spools}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.filaments.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.filaments.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
</div>
{/* Product stats */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">SKU-ovi</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.products.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.products.out_of_stock}</p>
</div>
<div>
<p className="text-sm text-white/40">Vrednost inventara</p>
<p className="text-2xl font-bold text-yellow-400">
{inventoryStats.products.inventory_value.toLocaleString('sr-RS')} RSD
</p>
</div>
</div>
{/* Category breakdown */}
{inventoryStats.products.by_category && Object.keys(inventoryStats.products.by_category).length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-white/40 mb-2">Po kategoriji</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{Object.entries(inventoryStats.products.by_category).map(([cat, count]) => (
<div key={cat} className="bg-white/[0.06] rounded p-3">
<p className="text-xs text-white/40">{CATEGORY_LABELS[cat] || cat}</p>
<p className="text-lg font-bold text-white">{count}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Combined */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Ukupno</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-white/40">Ukupno SKU-ova</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_skus}</p>
</div>
<div>
<p className="text-sm text-white/40">Ukupno jedinica</p>
<p className="text-2xl font-bold text-white">{inventoryStats.combined.total_units}</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju</p>
<p className="text-2xl font-bold text-red-400">{inventoryStats.combined.out_of_stock}</p>
</div>
</div>
</div>
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o inventaru nisu dostupni. Proverite API konekciju.</p>
{/* Fallback from direct data */}
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-white/40">Filamenata</p>
<p className="text-2xl font-bold text-white">{filaments.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Proizvoda</p>
<p className="text-2xl font-bold text-white">{products.length}</p>
</div>
<div>
<p className="text-sm text-white/40">Nisko stanje (fil.)</p>
<p className="text-2xl font-bold text-yellow-400">
{filaments.filter(f => f.kolicina <= 2 && f.kolicina > 0).length}
</p>
</div>
<div>
<p className="text-sm text-white/40">Nema na stanju (fil.)</p>
<p className="text-2xl font-bold text-red-400">
{filaments.filter(f => f.kolicina === 0).length}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Prodaja Tab */}
{activeTab === 'prodaja' && (
<div className="space-y-6">
{salesStats ? (
<>
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-2">Aktivni popusti</h3>
<p className="text-3xl font-bold text-purple-400">{salesStats.total_active_sales}</p>
</div>
{salesStats.filament_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Filamenti na popustu ({salesStats.filament_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.filament_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-purple-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.product_sales.length > 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Proizvodi na popustu ({salesStats.product_sales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Naziv</th>
<th className="px-3 py-2 text-left text-white/60">Kategorija</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Originalna cena</th>
<th className="px-3 py-2 text-left text-white/60">Cena sa popustom</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{salesStats.product_sales.map(sale => (
<tr key={sale.id}>
<td className="px-3 py-2 text-white/90">{sale.name}</td>
<td className="px-3 py-2 text-white/40">{CATEGORY_LABELS[sale.category] || sale.category}</td>
<td className="px-3 py-2 text-orange-300">-{sale.sale_percentage}%</td>
<td className="px-3 py-2 text-white/40 line-through">{sale.original_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-green-400 font-bold">{sale.sale_price.toLocaleString('sr-RS')} RSD</td>
<td className="px-3 py-2 text-white/40">
{sale.sale_end_date ? new Date(sale.sale_end_date).toLocaleDateString('sr-RS') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{salesStats.filament_sales.length === 0 && salesStats.product_sales.length === 0 && (
<div className="bg-white/[0.04] rounded-2xl p-6 text-center">
<p className="text-white/40">Nema aktivnih popusta</p>
</div>
)}
</>
) : (
<div className="bg-white/[0.04] rounded-2xl p-6">
<p className="text-white/40">Podaci o prodaji nisu dostupni. Proverite API konekciju.</p>
<div className="mt-4">
<p className="text-sm text-white/30">
Filamenti sa popustom: {filaments.filter(f => f.sale_active).length}
</p>
<p className="text-sm text-white/30">
Proizvodi sa popustom: {products.filter(p => p.sale_active).length}
</p>
</div>
</div>
)}
</div>
)}
{/* Posetioci Tab */}
{activeTab === 'posetioci' && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Analitika posetilaca</h3>
<p className="text-white/40 mb-4">
Matomo analitika je dostupna na eksternom dashboardu.
</p>
<a
href="https://analytics.demirix.dev"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Otvori Matomo analitiku
</a>
<p className="text-xs text-white/30 mt-3">
analytics.demirix.dev - Site ID: 7
</p>
</div>
)}
{/* Nabavka Tab */}
{activeTab === 'nabavka' && (
<div className="space-y-6">
<div className="bg-white/[0.04] rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-4">Preporuke za nabavku</h3>
<p className="text-white/40 text-sm mb-4">Na osnovu trenutnog stanja inventara</p>
{/* Critical (out of stock) */}
{(() => {
const critical = filaments.filter(f => f.kolicina === 0);
return critical.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-red-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-red-500 inline-block" />
Kriticno - nema na stanju ({critical.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{critical.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-red-400 font-bold">0</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* Warning (low stock) */}
{(() => {
const warning = filaments.filter(f => f.kolicina > 0 && f.kolicina <= 2);
return warning.length > 0 ? (
<div className="mb-6">
<h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-yellow-500 inline-block" />
Upozorenje - nisko stanje ({warning.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Tip</th>
<th className="px-3 py-2 text-left text-white/60">Finis</th>
<th className="px-3 py-2 text-left text-white/60">Boja</th>
<th className="px-3 py-2 text-left text-white/60">Stanje</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{warning.map(f => (
<tr key={f.id}>
<td className="px-3 py-2 text-white/90">{f.tip}</td>
<td className="px-3 py-2 text-white/60">{f.finish}</td>
<td className="px-3 py-2 text-white/90 flex items-center gap-2">
{f.boja_hex && <div className="w-4 h-4 rounded border border-white/[0.08]" style={{ backgroundColor: f.boja_hex }} />}
{f.boja}
</td>
<td className="px-3 py-2 text-yellow-400 font-bold">{f.kolicina}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null;
})()}
{/* OK */}
{(() => {
const ok = filaments.filter(f => f.kolicina > 2);
return (
<div>
<h4 className="text-sm font-medium text-green-400 mb-2 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500 inline-block" />
Dobro stanje ({ok.length})
</h4>
<p className="text-sm text-white/30">{ok.length} filamenata ima dovoljno na stanju (3+)</p>
</div>
);
})()}
{/* Product restock */}
{(() => {
const lowProducts = products.filter(p => p.stock <= 2);
return lowProducts.length > 0 ? (
<div className="mt-6">
<h4 className="text-sm font-medium text-orange-400 mb-2">Proizvodi za nabavku ({lowProducts.length})</h4>
<div className="space-y-1">
{lowProducts.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="text-white/60">{p.name}</span>
<span className={p.stock === 0 ? 'text-red-400 font-bold' : 'text-yellow-400'}>
{p.stock}
</span>
</div>
))}
</div>
</div>
) : null;
})()}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,448 +0,0 @@
'use client'
import { useState, useEffect } from 'react';
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 {
id: string;
name: string;
hex: string;
cena_refill?: number;
cena_spulna?: number;
createdAt?: string;
updatedAt?: string;
}
export default function BojePage() {
const [colors, setColors] = useState<Color[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingColor, setEditingColor] = useState<Color | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedColors, setSelectedColors] = useState<Set<string>>(new Set());
// Fetch colors
const fetchColors = async () => {
try {
setLoading(true);
const colors = await colorService.getAll();
setColors(colors.sort((a: Color, b: Color) => a.name.localeCompare(b.name)));
} catch (err) {
setError('Greska pri ucitavanju boja');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchColors();
}, []);
const handleSave = async (color: Partial<Color>) => {
try {
if (color.id) {
await colorService.update(color.id, {
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
} else {
await colorService.create({
name: color.name!,
hex: color.hex!,
cena_refill: color.cena_refill,
cena_spulna: color.cena_spulna
});
}
setEditingColor(null);
setShowAddForm(false);
fetchColors();
} catch (err) {
setError('Greska pri cuvanju boje');
console.error('Save error:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovu boju?')) {
return;
}
try {
await colorService.delete(id);
fetchColors();
} catch (err) {
setError('Greska pri brisanju boje');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedColors.size === 0) {
setError('Molimo izaberite boje za brisanje');
return;
}
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedColors.size} boja?`)) {
return;
}
try {
await Promise.all(Array.from(selectedColors).map(id => colorService.delete(id)));
setSelectedColors(new Set());
fetchColors();
} catch (err) {
setError('Greska pri brisanju boja');
console.error('Bulk delete error:', err);
}
};
const toggleColorSelection = (colorId: string) => {
const newSelection = new Set(selectedColors);
if (newSelection.has(colorId)) {
newSelection.delete(colorId);
} else {
newSelection.add(colorId);
}
setSelectedColors(newSelection);
};
const toggleSelectAll = () => {
const filteredColors = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
const allFilteredSelected = filteredColors.every(c => selectedColors.has(c.id));
if (allFilteredSelected) {
setSelectedColors(new Set());
} else {
setSelectedColors(new Set(filteredColors.map(c => c.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Upravljanje bojama</h1>
<p className="text-white/40 mt-1">{colors.length} boja ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingColor && (
<button
onClick={() => setShowAddForm(true)}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Dodaj novu boju
</button>
)}
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
{selectedColors.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Obrisi izabrane ({selectedColors.size})
</button>
)}
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Add Form */}
{showAddForm && (
<ColorForm
color={{}}
onSave={handleSave}
onCancel={() => {
setShowAddForm(false);
}}
/>
)}
{/* Search Bar */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi boje..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Colors Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={(() => {
const filtered = colors.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
);
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
})()}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Boja</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Naziv</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Hex kod</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Refil</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Cena Spulna</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{colors
.filter(color =>
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((color) => (
<tr key={color.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedColors.has(color.id)}
onChange={() => toggleColorSelection(color.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-10 h-10 rounded border-2 border-white/[0.08]"
style={{ backgroundColor: color.hex }}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{color.hex}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-green-400">
{(color.cena_refill || 3499).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-blue-400">
{(color.cena_spulna || 3999).toLocaleString('sr-RS')} RSD
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setEditingColor(color)}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(color.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Edit Modal */}
{editingColor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white/[0.04] rounded-2xl shadow-xl max-w-md w-full max-h-[90vh] overflow-auto">
<div className="p-6">
<h3 className="text-xl font-bold mb-4 text-white">Izmeni boju</h3>
<ColorForm
color={editingColor}
onSave={(color) => {
handleSave(color);
setEditingColor(null);
}}
onCancel={() => setEditingColor(null)}
isModal={true}
/>
</div>
</div>
</div>
)}
</div>
);
}
// Color Form Component
function ColorForm({
color,
onSave,
onCancel,
isModal = false
}: {
color: Partial<Color>,
onSave: (color: Partial<Color>) => void,
onCancel: () => void,
isModal?: boolean
}) {
const [formData, setFormData] = useState({
name: color.name || '',
hex: color.hex || '#000000',
cena_refill: color.cena_refill || 3499,
cena_spulna: color.cena_spulna || 3999,
});
const isBambuLabColor = !!(formData.name && Object.prototype.hasOwnProperty.call(bambuLabColors, formData.name));
const bambuHex = formData.name ? getColorHex(formData.name) : null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
setFormData({
...formData,
[name]: type === 'number' ? parseInt(value) || 0 : value
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const hexToSave = isBambuLabColor && bambuHex ? bambuHex : formData.hex;
onSave({
...color,
name: formData.name,
hex: hexToSave,
cena_refill: formData.cena_refill,
cena_spulna: formData.cena_spulna
});
};
return (
<div className={isModal ? "" : "p-6 bg-white/[0.04] rounded-2xl shadow"}>
{!isModal && (
<h2 className="text-xl font-bold mb-4 text-white">
{color.id ? 'Izmeni boju' : 'Dodaj novu boju'}
</h2>
)}
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Naziv boje</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="npr. Crvena"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Hex kod boje</label>
<div className="flex gap-2 items-center">
<input
type="color"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
disabled={isBambuLabColor}
className={`w-12 h-12 p-1 border-2 border-white/[0.08] rounded-md ${isBambuLabColor ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
style={{ backgroundColor: isBambuLabColor && bambuHex ? bambuHex : formData.hex }}
/>
<input
type="text"
name="hex"
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
onChange={handleChange}
required
readOnly={isBambuLabColor}
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#000000"
className={`flex-1 px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500 ${isBambuLabColor ? 'bg-[#060a14] cursor-not-allowed' : ''}`}
/>
</div>
{isBambuLabColor && (
<p className="text-xs text-white/40 mt-1">
Bambu Lab predefinisana boja - hex kod se ne moze menjati
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Refil</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3499"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Cena Spulna</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna}
onChange={handleChange}
required
min="0"
step="1"
placeholder="3999"
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,990 +0,0 @@
'use client'
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
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';
import '@/src/styles/select.css';
// Colors that only come as refills (no spools)
const REFILL_ONLY_COLORS = [
'Beige',
'Light Gray',
'Yellow',
'Orange',
'Gold',
'Bright Green',
'Pink',
'Magenta',
'Maroon Red',
'Purple',
'Turquoise',
'Cobalt Blue',
'Brown',
'Bronze',
'Silver',
'Blue Grey',
'Dark Gray'
];
// Helper function to check if a filament is spool-only
const isSpoolOnly = (finish?: string, type?: string): boolean => {
return finish === 'Translucent' || finish === 'Metal' || finish === 'Silk+' || finish === 'Wood' || (type === 'PPA' && finish === 'CF') || type === 'PA6' || type === 'PC';
};
// Helper function to check if a filament should be refill-only
const isRefillOnly = (color: string, finish?: string, type?: string): boolean => {
// If the finish/type combination is spool-only, then it's not refill-only
if (isSpoolOnly(finish, type)) {
return false;
}
// Translucent finish always has spool option
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);
};
// Helper function to filter colors based on material and finish
const getFilteredColors = (colors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>, type?: string, finish?: string) => {
// PPA CF only has black color
if (type === 'PPA' && finish === 'CF') {
return colors.filter(color => color.name.toLowerCase() === 'black');
}
return colors;
};
interface FilamentWithId extends Filament {
id: string;
createdAt?: string;
updatedAt?: string;
boja_hex?: string;
}
// 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', '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']
};
export default function FilamentiPage() {
const router = useRouter();
const [filaments, setFilaments] = useState<FilamentWithId[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingFilament, setEditingFilament] = useState<FilamentWithId | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [sortField, setSortField] = useState<string>('boja');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [selectedFilaments, setSelectedFilaments] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [availableColors, setAvailableColors] = useState<Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>>([]);
// Fetch filaments
const fetchFilaments = async () => {
try {
setLoading(true);
const filaments = await filamentService.getAll();
setFilaments(filaments);
} catch (err) {
setError('Greska pri ucitavanju filamenata');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
const fetchAllData = async () => {
// Fetch both filaments and colors
await fetchFilaments();
try {
const colors = await colorService.getAll();
setAvailableColors(colors.sort((a: any, b: any) => a.name.localeCompare(b.name)));
} catch (error) {
console.error('Error loading colors:', error);
}
};
useEffect(() => {
fetchAllData();
}, []);
// Sorting logic
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Filter and sort filaments
const filteredAndSortedFilaments = useMemo(() => {
// First, filter by search term
let filtered = filaments;
if (searchTerm) {
const search = searchTerm.toLowerCase();
filtered = filaments.filter(f =>
f.tip?.toLowerCase().includes(search) ||
f.finish?.toLowerCase().includes(search) ||
f.boja?.toLowerCase().includes(search) ||
f.cena?.toLowerCase().includes(search)
);
}
// Then sort if needed
if (!sortField) return filtered;
return [...filtered].sort((a, b) => {
let aVal = a[sortField as keyof FilamentWithId];
let bVal = b[sortField as keyof FilamentWithId];
// Handle null/undefined values
if (aVal === null || aVal === undefined) aVal = '';
if (bVal === null || bVal === undefined) bVal = '';
// Handle date fields
if (sortField === 'created_at' || sortField === 'updated_at') {
const aDate = new Date(String(aVal));
const bDate = new Date(String(bVal));
return sortOrder === 'asc' ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
}
// Handle numeric fields
if (sortField === 'kolicina' || sortField === 'refill' || sortField === 'spulna') {
const aNum = Number(aVal) || 0;
const bNum = Number(bVal) || 0;
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
}
// String comparison for other fields
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [filaments, sortField, sortOrder, searchTerm]);
const handleSave = async (filament: Partial<FilamentWithId>) => {
try {
// Extract only the fields the API expects
const { id, ...dataForApi } = filament;
// Ensure numeric fields are numbers
const cleanData = {
tip: dataForApi.tip || 'PLA',
finish: dataForApi.finish || 'Basic',
boja: dataForApi.boja || '',
boja_hex: dataForApi.boja_hex || '#000000',
refill: Number(dataForApi.refill) || 0,
spulna: Number(dataForApi.spulna) || 0,
cena: dataForApi.cena || '3499'
};
// Validate required fields
if (!cleanData.tip || !cleanData.finish || !cleanData.boja) {
setError('Tip, Finish, and Boja are required fields');
return;
}
if (id) {
await filamentService.update(id, cleanData);
trackEvent('Admin', 'Update Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
} else {
await filamentService.create(cleanData);
trackEvent('Admin', 'Create Filament', `${cleanData.tip} ${cleanData.finish} ${cleanData.boja}`);
}
setEditingFilament(null);
setShowAddForm(false);
fetchAllData();
} catch (err: any) {
if (err.response?.status === 401 || err.response?.status === 403) {
setError('Sesija je istekla. Molimo prijavite se ponovo.');
setTimeout(() => {
router.push('/upadaj');
}, 2000);
} else {
// Extract error message properly
let errorMessage = 'Greska pri cuvanju filamenata';
if (err.response?.data?.error) {
errorMessage = err.response.data.error;
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (typeof err.response?.data === 'string') {
errorMessage = err.response.data;
} else if (err.message) {
errorMessage = err.message;
}
setError(errorMessage);
console.error('Save error:', err.response?.data || err.message);
}
}
};
const handleDelete = async (id: string) => {
if (!confirm('Da li ste sigurni da zelite obrisati ovaj filament?')) {
return;
}
try {
await filamentService.delete(id);
fetchAllData();
} catch (err) {
setError('Greska pri brisanju filamenata');
console.error('Delete error:', err);
}
};
const handleBulkDelete = async () => {
if (selectedFilaments.size === 0) {
setError('Molimo izaberite filamente za brisanje');
return;
}
if (!confirm(`Da li ste sigurni da zelite obrisati ${selectedFilaments.size} filamenata?`)) {
return;
}
try {
// Delete all selected filaments
await Promise.all(Array.from(selectedFilaments).map(id => filamentService.delete(id)));
setSelectedFilaments(new Set());
fetchAllData();
} catch (err) {
setError('Greska pri brisanju filamenata');
console.error('Bulk delete error:', err);
}
};
const toggleFilamentSelection = (filamentId: string) => {
const newSelection = new Set(selectedFilaments);
if (newSelection.has(filamentId)) {
newSelection.delete(filamentId);
} else {
newSelection.add(filamentId);
}
setSelectedFilaments(newSelection);
};
const toggleSelectAll = () => {
if (selectedFilaments.size === filteredAndSortedFilaments.length) {
setSelectedFilaments(new Set());
} else {
setSelectedFilaments(new Set(filteredAndSortedFilaments.map(f => f.id)));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Filamenti</h1>
<p className="text-white/40 mt-1">{filaments.length} filamenata ukupno</p>
</div>
<div className="flex flex-wrap gap-2">
{!showAddForm && !editingFilament && (
<button
onClick={() => {
setShowAddForm(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="px-3 sm:px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm sm:text-base"
>
Dodaj novi
</button>
)}
{selectedFilaments.size > 0 && (
<button
onClick={handleBulkDelete}
className="px-3 sm:px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm sm:text-base"
>
Obrisi izabrane ({selectedFilaments.size})
</button>
)}
<SaleManager
filaments={filaments}
selectedFilaments={selectedFilaments}
onSaleUpdate={fetchFilaments}
/>
<BulkFilamentPriceEditor
filaments={filaments}
onUpdate={fetchFilaments}
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Search Bar and Sorting */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Input */}
<div className="relative">
<input
type="text"
placeholder="Pretrazi po tipu, finishu, boji ili ceni..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 pl-10 pr-4 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-2xl focus:outline-none focus:border-blue-500"
/>
<svg className="absolute left-3 top-2.5 h-5 w-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Sort Dropdown */}
<div>
<select
value={`${sortField || 'boja'}-${sortOrder || 'asc'}`}
onChange={(e) => {
try {
const [field, order] = e.target.value.split('-');
if (field && order) {
setSortField(field);
setSortOrder(order as 'asc' | 'desc');
}
} catch (error) {
console.error('Sort change error:', error);
}
}}
className="custom-select w-full px-3 py-2 text-white/60 bg-white/[0.04] border border-white/[0.08] rounded-md focus:outline-none focus:border-blue-500"
>
<option value="boja-asc">Sortiraj po: Boja (A-Z)</option>
<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: Finis (A-Z)</option>
<option value="finish-desc">Sortiraj po: Finis (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 azurirano</option>
<option value="updated_at-asc">Sortiraj po: Prvo azurirano</option>
<option value="kolicina-desc">Sortiraj po: Kolicina (visoka-niska)</option>
<option value="kolicina-asc">Sortiraj po: Kolicina (niska-visoka)</option>
</select>
</div>
</div>
{/* Add/Edit Form */}
{(showAddForm || editingFilament) && (
<div>
<FilamentForm
key={editingFilament?.id || 'new'}
filament={editingFilament || {}}
filaments={filaments}
availableColors={availableColors}
onSave={handleSave}
onCancel={() => {
setEditingFilament(null);
setShowAddForm(false);
}}
/>
</div>
)}
{/* Filaments Table */}
<div className="overflow-x-auto bg-white/[0.04] rounded-2xl shadow">
<table className="min-w-full divide-y divide-white/[0.06]">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={filteredAndSortedFilaments.length > 0 && selectedFilaments.size === filteredAndSortedFilaments.length}
onChange={toggleSelectAll}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</th>
<th onClick={() => handleSort('tip')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('finish')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Finis {sortField === 'finish' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('boja')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('refill')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('spulna')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('kolicina')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Kolicina {sortField === 'kolicina' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th onClick={() => handleSort('cena')} className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider cursor-pointer hover:bg-white/[0.06]">
Cena {sortField === 'cena' && (sortOrder === 'asc' ? '\u2191' : '\u2193')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Popust</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/60 uppercase tracking-wider">Akcije</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{filteredAndSortedFilaments.map((filament) => (
<tr key={filament.id} className="hover:bg-white/[0.06]">
<td className="px-6 py-4 whitespace-nowrap">
<input
type="checkbox"
checked={selectedFilaments.has(filament.id)}
onChange={() => toggleFilamentSelection(filament.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-white/[0.06] dark:border-white/[0.08]"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.tip}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.finish}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
<div className="flex items-center gap-2">
{filament.boja_hex && (
<div
className="w-7 h-7 rounded border border-white/[0.08]"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span>{filament.boja}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.refill > 0 ? (
<span className="text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">
{filament.spulna > 0 ? (
<span className="text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-white/30">0</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white/90">{filament.kolicina}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-bold text-white/90">
{(() => {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return '-';
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
const colorData = availableColors.find(c => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
return (
<>
{hasRefill && (
<span className="text-green-400">
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span className="text-blue-400">
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-white/40">RSD</span>
</>
);
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{filament.sale_active && filament.sale_percentage ? (
<div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-200">
-{filament.sale_percentage}%
</span>
{filament.sale_end_date && (
<div className="text-xs text-white/40 mt-1">
do {new Date(filament.sale_end_date).toLocaleDateString('sr-RS')}
</div>
)}
</div>
) : (
<span className="text-white/30">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => {
setEditingFilament(filament);
setShowAddForm(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="text-blue-400 hover:text-blue-300 mr-3"
title="Izmeni"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(filament.id)}
className="text-red-400 hover:text-red-300"
title="Obrisi"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Filament Form Component
function FilamentForm({
filament,
filaments,
availableColors,
onSave,
onCancel
}: {
filament: Partial<FilamentWithId>,
filaments: FilamentWithId[],
availableColors: Array<{id: string, name: string, hex: string, cena_refill?: number, cena_spulna?: number}>,
onSave: (filament: Partial<FilamentWithId>) => void,
onCancel: () => void
}) {
const [formData, setFormData] = useState({
tip: filament.tip || (filament.id ? '' : 'PLA'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: isSpoolOnly(filament.finish, filament.tip) ? 0 : (filament.refill || 0),
spulna: isRefillOnly(filament.boja || '', filament.finish, filament.tip) ? 0 : (filament.spulna || 0),
kolicina: filament.kolicina || 0,
cena: '',
cena_refill: 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
useEffect(() => {
let refillPrice = 0;
let spulnaPrice = 0;
if (filament.cena) {
const prices = filament.cena.split('/');
refillPrice = parseInt(prices[0]) || 0;
spulnaPrice = prices.length > 1 ? parseInt(prices[1]) || 0 : parseInt(prices[0]) || 0;
}
const colorData = availableColors.find(c => c.name === filament.boja);
if (!refillPrice && colorData?.cena_refill) refillPrice = colorData.cena_refill;
if (!spulnaPrice && colorData?.cena_spulna) spulnaPrice = colorData.cena_spulna;
setFormData({
tip: filament.tip || (filament.id ? '' : 'PLA'),
finish: filament.finish || (filament.id ? '' : 'Basic'),
boja: filament.boja || '',
boja_hex: filament.boja_hex || '',
refill: filament.refill || 0,
spulna: filament.spulna || 0,
kolicina: filament.kolicina || 0,
cena: filament.cena || '',
cena_refill: refillPrice || 3499,
cena_spulna: spulnaPrice || 3999,
});
setIsInitialLoad(true);
}, [filament]);
// Update prices when color selection changes (but not on initial load)
useEffect(() => {
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
if (formData.boja && availableColors.length > 0) {
const colorData = availableColors.find(c => c.name === formData.boja);
if (colorData) {
setFormData(prev => ({
...prev,
cena_refill: colorData.cena_refill || prev.cena_refill,
cena_spulna: colorData.cena_spulna || prev.cena_spulna,
}));
}
}
}, [formData.boja, availableColors.length]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name === 'refill' || name === 'spulna' || name === 'cena_refill' || name === 'cena_spulna') {
const numValue = parseInt(value) || 0;
setFormData({
...formData,
[name]: numValue
});
} else if (name === 'tip') {
const newTypeFinishes = FINISH_OPTIONS_BY_TYPE[value] || [];
const resetFinish = !newTypeFinishes.includes(formData.finish);
const spoolOnly = isSpoolOnly(formData.finish, value);
const needsColorReset = value === 'PPA' && formData.finish === 'CF' && formData.boja.toLowerCase() !== 'black';
setFormData({
...formData,
[name]: value,
...(resetFinish ? { finish: '' } : {}),
...(spoolOnly ? { refill: 0 } : {}),
...(needsColorReset ? { boja: '' } : {})
});
} else if (name === 'boja') {
const refillOnly = isRefillOnly(value, formData.finish, formData.tip);
setFormData({
...formData,
[name]: value,
...(refillOnly ? { spulna: 0 } : {})
});
} else if (name === 'finish') {
const refillOnly = isRefillOnly(formData.boja, value, formData.tip);
const spoolOnly = isSpoolOnly(value, formData.tip);
const needsColorReset = formData.tip === 'PPA' && value === 'CF' && formData.boja.toLowerCase() !== 'black';
setFormData({
...formData,
[name]: value,
...(refillOnly ? { spulna: 0 } : {}),
...(spoolOnly ? { refill: 0 } : {}),
...(needsColorReset ? { boja: '' } : {})
});
} else {
setFormData({
...formData,
[name]: value
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const totalQuantity = formData.refill + formData.spulna;
if (totalQuantity === 0) {
alert('Kolicina mora biti veca od 0. Dodajte refill ili spulna.');
return;
}
const refillPrice = formData.cena_refill;
const spoolPrice = formData.cena_spulna;
let priceString = '';
if (formData.refill > 0 && formData.spulna > 0) {
priceString = `${refillPrice}/${spoolPrice}`;
} else if (formData.refill > 0) {
priceString = String(refillPrice);
} else if (formData.spulna > 0) {
priceString = String(spoolPrice);
} else {
priceString = '3499/3999';
}
const dataToSave = {
id: filament.id,
tip: formData.tip,
finish: formData.finish,
boja: formData.boja,
boja_hex: formData.boja_hex,
refill: formData.refill,
spulna: formData.spulna,
cena: priceString
};
onSave(dataToSave);
};
return (
<div className="p-6 bg-white/[0.04] rounded-2xl shadow">
<h2 className="text-xl font-bold mb-4 text-white">
{filament.id ? 'Izmeni filament' : 'Dodaj novi filament'}
</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Tip</label>
<select
name="tip"
value={formData.tip}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi tip</option>
<option value="ABS">ABS</option>
<option value="ASA">ASA</option>
<option value="PA6">PA6</option>
<option value="PAHT">PAHT</option>
<option value="PC">PC</option>
<option value="PET">PET</option>
<option value="PETG">PETG</option>
<option value="PLA">PLA</option>
<option value="PPA">PPA</option>
<option value="PPS">PPS</option>
<option value="TPU">TPU</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Finis</label>
<select
name="finish"
value={formData.finish}
onChange={handleChange}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberi finis</option>
{(FINISH_OPTIONS_BY_TYPE[formData.tip] || []).map(finish => (
<option key={finish} value={finish}>{finish}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Boja</label>
<select
name="boja"
value={formData.boja}
onChange={(e) => {
const selectedColorName = e.target.value;
let hexValue = formData.boja_hex;
const dbColor = availableColors.find(c => c.name === selectedColorName);
if (dbColor) {
hexValue = dbColor.hex;
}
handleChange({
target: {
name: 'boja',
value: selectedColorName
}
} as any);
setFormData(prev => ({
...prev,
boja_hex: hexValue,
cena_refill: dbColor?.cena_refill || prev.cena_refill || 3499,
cena_spulna: dbColor?.cena_spulna || prev.cena_spulna || 3999
}));
}}
required
className="custom-select w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Izaberite boju</option>
{getFilteredColors(availableColors, formData.tip, formData.finish).map(color => (
<option key={color.id} value={color.name}>
{color.name}
</option>
))}
<option value="custom">Druga boja...</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
{formData.boja && formData.boja !== 'custom' ? `Hex kod za ${formData.boja}` : 'Hex kod boje'}
</label>
<div className="flex items-center gap-2">
<input
type="color"
name="boja_hex"
value={formData.boja_hex || '#000000'}
onChange={handleChange}
disabled={false}
className="w-full h-10 px-1 py-1 border border-white/[0.08] rounded-md bg-white/[0.06] cursor-pointer"
/>
{formData.boja_hex && (
<span className="text-sm text-white/40">{formData.boja_hex}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-green-400">Cena Refila</span>
</label>
<input
type="number"
name="cena_refill"
value={formData.cena_refill || availableColors.find(c => c.name === formData.boja)?.cena_refill || 3499}
onChange={handleChange}
min="0"
step="1"
placeholder="3499"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-green-400 font-bold focus:outline-none focus:ring-2 focus:ring-green-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
<span className="text-blue-400">Cena Spulne</span>
</label>
<input
type="number"
name="cena_spulna"
value={formData.cena_spulna || availableColors.find(c => c.name === formData.boja)?.cena_spulna || 3999}
onChange={handleChange}
min="0"
step="1"
placeholder="3999"
disabled={isRefillOnly(formData.boja, formData.finish)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish)
? 'bg-white/[0.08] cursor-not-allowed text-white/40'
: 'bg-white/[0.06] text-blue-400 font-bold focus:outline-none focus:ring-2 focus:ring-blue-500'
}`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Refil
{isSpoolOnly(formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo spulna postoji)</span>
)}
</label>
<input
type="number"
name="refill"
value={formData.refill}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isSpoolOnly(formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isSpoolOnly(formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">
Spulna
{isRefillOnly(formData.boja, formData.finish, formData.tip) && (
<span className="text-xs text-white/40 ml-2">(samo refil postoji)</span>
)}
</label>
<input
type="number"
name="spulna"
value={formData.spulna}
onChange={handleChange}
min="0"
step="1"
placeholder="0"
disabled={isRefillOnly(formData.boja, formData.finish, formData.tip)}
className={`w-full px-3 py-2 border border-white/[0.08] rounded-md ${
isRefillOnly(formData.boja, formData.finish, formData.tip)
? 'bg-white/[0.08] cursor-not-allowed'
: 'bg-white/[0.06]'
} text-white/90 focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-white/60">Ukupna kolicina</label>
<input
type="number"
name="kolicina"
value={formData.refill + formData.spulna}
readOnly
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.08] text-white/90 cursor-not-allowed"
/>
</div>
<div className="md:col-span-2 flex justify-end gap-4 mt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.12] transition-colors"
>
Otkazi
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Sacuvaj
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,8 +0,0 @@
'use client';
import { AdminLayout } from '@/src/components/layout/AdminLayout';
import { usePathname } from 'next/navigation';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return <AdminLayout currentPath={pathname}>{children}</AdminLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,360 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { filamentService, productService, analyticsService } from '@/src/services/api';
import { Filament } from '@/src/types/filament';
import { Product, SalesStats } from '@/src/types/product';
export default function ProdajaPage() {
const [salesStats, setSalesStats] = useState<SalesStats | null>(null);
const [filaments, setFilaments] = useState<(Filament & { id: string })[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Filament sale form
const [filamentSalePercentage, setFilamentSalePercentage] = useState(10);
const [filamentSaleEndDate, setFilamentSaleEndDate] = useState('');
const [filamentSaleEnabled, setFilamentSaleEnabled] = useState(true);
// Product sale form
const [productSalePercentage, setProductSalePercentage] = useState(10);
const [productSaleEndDate, setProductSaleEndDate] = useState('');
const [productSaleEnabled, setProductSaleEnabled] = useState(true);
const [savingFilamentSale, setSavingFilamentSale] = useState(false);
const [savingProductSale, setSavingProductSale] = useState(false);
const fetchData = async () => {
try {
setLoading(true);
const [filamentData, productData, salesData] = await Promise.all([
filamentService.getAll(),
productService.getAll().catch(() => []),
analyticsService.getSales().catch(() => null),
]);
setFilaments(filamentData);
setProducts(productData);
setSalesStats(salesData);
} catch (err) {
setError('Greska pri ucitavanju podataka');
console.error('Fetch error:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const getCurrentDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
const handleFilamentBulkSale = async () => {
if (!confirm('Primeniti popust na SVE filamente?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: filamentSalePercentage,
saleEndDate: filamentSaleEndDate || undefined,
enableSale: filamentSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za filamente');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleClearFilamentSales = async () => {
if (!confirm('Ukloniti SVE popuste sa filamenata?')) return;
setSavingFilamentSale(true);
try {
await filamentService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingFilamentSale(false);
}
};
const handleProductBulkSale = async () => {
if (!confirm('Primeniti popust na SVE proizvode?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: productSalePercentage,
saleEndDate: productSaleEndDate || undefined,
enableSale: productSaleEnabled,
});
await fetchData();
} catch (err) {
setError('Greska pri azuriranju popusta za proizvode');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const handleClearProductSales = async () => {
if (!confirm('Ukloniti SVE popuste sa proizvoda?')) return;
setSavingProductSale(true);
try {
await productService.updateBulkSale({
salePercentage: 0,
enableSale: false,
});
await fetchData();
} catch (err) {
setError('Greska pri brisanju popusta');
console.error(err);
} finally {
setSavingProductSale(false);
}
};
const activeFilamentSales = filaments.filter(f => f.sale_active);
const activeProductSales = products.filter(p => p.sale_active);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje...</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-black text-white tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>Upravljanje popustima</h1>
<p className="text-white/40 mt-1">
{activeFilamentSales.length + activeProductSales.length} aktivnih popusta
</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
{/* Overview Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Filamenti sa popustom</p>
<p className="text-3xl font-bold text-purple-400 mt-1">{activeFilamentSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {filaments.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Proizvodi sa popustom</p>
<p className="text-3xl font-bold text-orange-400 mt-1">{activeProductSales.length}</p>
<p className="text-xs text-white/30 mt-1">od {products.length} ukupno</p>
</div>
<div className="bg-white/[0.04] p-5 rounded-2xl">
<p className="text-sm text-white/40">Ukupno aktivnih</p>
<p className="text-3xl font-bold text-blue-400 mt-1">
{activeFilamentSales.length + activeProductSales.length}
</p>
</div>
</div>
{/* Filament Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na filamente</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={filamentSalePercentage}
onChange={(e) => setFilamentSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={filamentSaleEndDate}
onChange={(e) => setFilamentSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filamentSaleEnabled}
onChange={(e) => setFilamentSaleEnabled(e.target.checked)}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={filamentSaleEnabled ? 'text-green-400' : 'text-white/30'}>{filamentSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleFilamentBulkSale}
disabled={savingFilamentSale}
className="px-4 py-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50 text-sm"
>
{savingFilamentSale ? 'Cuvanje...' : 'Primeni na sve filamente'}
</button>
<button
onClick={handleClearFilamentSales}
disabled={savingFilamentSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Product Sale Controls */}
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Popusti na proizvode</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Procenat popusta (%)</label>
<input
type="number"
min="0"
max="100"
value={productSalePercentage}
onChange={(e) => setProductSalePercentage(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/60 mb-1">Kraj popusta (opciono)</label>
<input
type="datetime-local"
value={productSaleEndDate}
onChange={(e) => setProductSaleEndDate(e.target.value)}
min={getCurrentDateTime()}
className="w-full px-3 py-2 border border-white/[0.08] rounded-md bg-white/[0.06] text-white/90"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={productSaleEnabled}
onChange={(e) => setProductSaleEnabled(e.target.checked)}
className="w-4 h-4 text-orange-600"
/>
<span className="text-sm text-white/60">
Aktivan: <span className={productSaleEnabled ? 'text-green-400' : 'text-white/30'}>{productSaleEnabled ? 'Da' : 'Ne'}</span>
</span>
</label>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleProductBulkSale}
disabled={savingProductSale}
className="px-4 py-2 bg-orange-600 text-white rounded-xl hover:bg-orange-700 disabled:opacity-50 text-sm"
>
{savingProductSale ? 'Cuvanje...' : 'Primeni na sve proizvode'}
</button>
<button
onClick={handleClearProductSales}
disabled={savingProductSale}
className="px-4 py-2 bg-white/[0.08] text-white/70 rounded-xl hover:bg-white/[0.1] disabled:opacity-50 text-sm"
>
Ukloni sve popuste
</button>
</div>
</div>
{/* Active Sales List */}
{(activeFilamentSales.length > 0 || activeProductSales.length > 0) && (
<div className="bg-white/[0.04] rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-4">Aktivni popusti</h2>
{activeFilamentSales.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-purple-400 mb-2">Filamenti ({activeFilamentSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Filament</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeFilamentSales.map(f => (
<tr key={f.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{f.tip} {f.finish} - {f.boja}</td>
<td className="px-3 py-2">
<span className="text-purple-300 font-medium">-{f.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{f.sale_end_date
? new Date(f.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeProductSales.length > 0 && (
<div>
<h3 className="text-sm font-medium text-orange-400 mb-2">Proizvodi ({activeProductSales.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-3 py-2 text-left text-white/60">Proizvod</th>
<th className="px-3 py-2 text-left text-white/60">Popust</th>
<th className="px-3 py-2 text-left text-white/60">Istice</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.06]">
{activeProductSales.map(p => (
<tr key={p.id} className="hover:bg-white/[0.06]">
<td className="px-3 py-2 text-white/90">{p.name}</td>
<td className="px-3 py-2">
<span className="text-orange-300 font-medium">-{p.sale_percentage}%</span>
</td>
<td className="px-3 py-2 text-white/40">
{p.sale_end_date
? new Date(p.sale_end_date).toLocaleDateString('sr-RS')
: 'Neograniceno'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,334 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
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 ZahteviPage() {
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: '' });
useEffect(() => {
fetchRequests();
}, []);
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-900/30 text-yellow-300',
approved: 'bg-green-900/30 text-green-300',
rejected: 'bg-red-900/30 text-red-300',
completed: 'bg-blue-900/30 text-blue-300'
};
return colors[status as keyof typeof colors] || 'bg-white/[0.06] text-white/60';
};
const getStatusLabel = (status: string) => {
const labels = {
pending: 'Na cekanju',
approved: 'Odobreno',
rejected: 'Odbijeno',
completed: 'Zavrseno'
};
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="flex items-center justify-center h-64">
<div className="text-white/40">Ucitavanje zahteva za boje...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-white">Zahtevi za Boje</h1>
<p className="text-white/40 mt-1">{requests.length} zahteva ukupno</p>
</div>
{error && (
<div className="p-4 bg-red-900/20 text-red-400 rounded">
{error}
</div>
)}
<div className="bg-white/[0.04] rounded-2xl shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/[0.06]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Boja
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Materijal/Finis
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Broj Zahteva
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Korisnik
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Datum
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/40 uppercase tracking-wider">
Akcije
</th>
</tr>
</thead>
<tbody className="bg-white/[0.04] divide-y divide-white/[0.06]">
{requests.map((request) => (
<tr key={request.id} className="hover:bg-white/[0.06]">
<td className="px-4 py-3">
<div>
<div className="font-medium text-white/90">{request.color_name}</div>
{request.description && (
<div className="text-sm text-white/40 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
href={request.reference_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
Pogledaj referencu
</a>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<div className="text-white/90">{request.material_type}</div>
{request.finish_type && (
<div className="text-white/40">{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-900/30 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-400 hover:underline">
{request.user_email}
</a>
) : (
<span className="text-white/30">Anonimno</span>
)}
{request.user_phone && (
<div className="mt-1">
<a href={`tel:${request.user_phone}`} className="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 bg-white/[0.06] border-white/[0.08] text-white/90"
>
<option value="">Izaberi status</option>
<option value="pending">Na cekanju</option>
<option value="approved">Odobreno</option>
<option value="rejected">Odbijeno</option>
<option value="completed">Zavrseno</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 bg-white/[0.06] border-white/[0.08] text-white/90"
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-white/30 mt-1">{request.admin_notes}</div>
)}
{request.processed_by && (
<div className="text-xs text-white/30 mt-1">
od {request.processed_by}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-white/40">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
<div className="text-xs text-white/30">
Obradjeno: {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-400 hover:text-green-300 text-sm"
>
Sacuvaj
</button>
<button
onClick={() => {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-white/40 hover:text-white/60 text-sm"
>
Otkazi
</button>
</div>
) : (
<div className="space-x-2">
<button
onClick={() => {
setEditingId(request.id);
setEditForm({
status: request.status,
admin_notes: request.admin_notes || ''
});
}}
className="text-blue-400 hover:text-blue-300 text-sm"
>
Izmeni
</button>
<button
onClick={() => handleDelete(request.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Obrisi
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-white/40">
Nema zahteva za boje
</div>
)}
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Ukupno Zahteva</div>
<div className="text-2xl font-bold text-white/90">
{requests.length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Na Cekanju</div>
<div className="text-2xl font-bold text-yellow-400">
{requests.filter(r => r.status === 'pending').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Odobreno</div>
<div className="text-2xl font-bold text-green-400">
{requests.filter(r => r.status === 'approved').length}
</div>
</div>
<div className="bg-white/[0.04] p-4 rounded-2xl shadow">
<div className="text-sm text-white/40">Zavrseno</div>
<div className="text-2xl font-bold text-blue-400">
{requests.filter(r => r.status === 'completed').length}
</div>
</div>
</div>
</div>
);
}

View File

@@ -32,8 +32,8 @@ export default function AdminLogin() {
// Track successful login
trackEvent('Admin', 'Login', 'Success');
// Redirect to admin dashboard using window.location for static export
window.location.href = '/upadaj/dashboard';
// Redirect to admin dashboard
router.push('/upadaj/dashboard');
} catch (err: any) {
setError('Neispravno korisničko ime ili lozinka');
console.error('Login error:', err);
@@ -44,7 +44,7 @@ export default function AdminLogin() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#060a14] py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="flex flex-col items-center">
<img
@@ -52,23 +52,20 @@ export default function AdminLogin() {
alt="Filamenteka"
className="h-40 w-auto mb-6 drop-shadow-lg"
/>
<h2
className="text-center text-3xl font-black text-white tracking-tight"
style={{ fontFamily: 'var(--font-display)' }}
>
<h2 className="text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Admin Prijava
</h2>
<p className="mt-2 text-center text-sm text-white/40">
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Prijavite se za upravljanje filamentima
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="rounded-xl bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-red-400">{error}</p>
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-3">
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Korisničko ime
@@ -79,7 +76,7 @@ export default function AdminLogin() {
type="text"
autoComplete="username"
required
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Korisničko ime"
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -95,7 +92,7 @@ export default function AdminLogin() {
type="password"
autoComplete="current-password"
required
className="appearance-none relative block w-full px-4 py-3 border border-white/[0.08] placeholder-white/30 text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500/40 sm:text-sm bg-white/[0.04]"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm dark:bg-gray-800"
placeholder="Lozinka"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -107,8 +104,7 @@ export default function AdminLogin() {
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ boxShadow: '0 4px 14px rgba(59, 130, 246, 0.3)' }}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Prijavljivanje...' : 'Prijavite se'}
</button>

View File

@@ -42,7 +42,7 @@ export default function ColorRequestsAdmin() {
const expiry = localStorage.getItem('tokenExpiry');
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
window.location.href = '/upadaj';
router.push('/upadaj');
}
};
@@ -89,7 +89,7 @@ export default function ColorRequestsAdmin() {
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-white/[0.06] text-gray-800 dark:text-white/60';
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) => {
@@ -112,20 +112,20 @@ export default function ColorRequestsAdmin() {
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
<div className="text-gray-500 dark:text-white/40">Učitavanje zahteva za boje...</div>
<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-[#060a14]">
<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"
href="/upadaj/dashboard"
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Inventar
@@ -145,42 +145,42 @@ export default function ColorRequestsAdmin() {
</div>
)}
<div className="bg-white dark:bg-white/[0.04] rounded-lg shadow overflow-hidden">
<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-white/[0.06]">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/40 uppercase tracking-wider">
<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-white/[0.04] divide-y divide-gray-200 dark:divide-white/[0.06]">
<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-white/[0.06]">
<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-white/40 mt-1">{request.description}</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">{request.description}</div>
)}
{request.reference_url && (
<a
@@ -198,7 +198,7 @@ export default function ColorRequestsAdmin() {
<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-white/40">{request.finish_type}</div>
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
)}
</div>
</td>
@@ -264,7 +264,7 @@ export default function ColorRequestsAdmin() {
)}
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-600 dark:text-white/40">
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(request.created_at)}
</div>
{request.processed_at && (
@@ -287,7 +287,7 @@ export default function ColorRequestsAdmin() {
setEditingId(null);
setEditForm({ status: '', admin_notes: '' });
}}
className="text-gray-600 dark:text-white/40 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
>
Otkaži
</button>
@@ -322,33 +322,33 @@ export default function ColorRequestsAdmin() {
</div>
{requests.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-white/40">
<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-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Ukupno Zahteva</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">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-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Na Čekanju</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-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Odobreno</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-white/[0.04] p-4 rounded-lg shadow">
<div className="text-sm text-gray-500 dark:text-white/40">Završeno</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>

View File

@@ -1,208 +0,0 @@
import type { Metadata } from 'next';
import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { Breadcrumb } from '@/src/components/layout/Breadcrumb';
export const metadata: Metadata = {
title: 'Uslovi Koriscenja',
description: 'Uslovi koriscenja sajta Filamenteka — informacije o privatnoj prodaji, proizvodima, cenama i pravima kupaca.',
alternates: {
canonical: 'https://filamenteka.rs/uslovi-koriscenja',
},
};
export default function UsloviKoriscenjaPage() {
return (
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<SiteHeader />
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<Breadcrumb items={[
{ label: 'Pocetna', href: '/' },
{ label: 'Uslovi Koriscenja' },
]} />
<article className="mt-6">
<h1
className="text-3xl sm:text-4xl font-black tracking-tight"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
Uslovi Koriscenja
</h1>
<p className="text-sm mt-2 mb-10" style={{ color: 'var(--text-muted)' }}>
Poslednje azuriranje: Februar 2026
</p>
<div className="space-y-8 leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
1. O sajtu
</h2>
<p>
Filamenteka (filamenteka.rs) je informativni katalog koji prikazuje ponudu
Bambu Lab opreme dostupne za privatnu prodaju. Sajt sluzi iskljucivo kao
prezentacija proizvoda i nije e-commerce prodavnica kupovina se ne vrsi
direktno preko ovog sajta.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
2. Privatna prodaja fizickog lica
</h2>
<p>
Filamenteka je privatna prodaja fizickog lica i nije registrovana prodavnica,
preduzetnik niti pravno lice. Ovo je kljucna pravna distinkcija koja znaci da
se na transakcije primenjuju pravila koja vaze za privatnu prodaju izmedju
fizickih lica, a ne pravila koja se odnose na potrosacku zastitu u kontekstu
registrovanih trgovaca.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
3. Proizvodi i stanje
</h2>
<p className="mb-3">
Proizvodi navedeni na sajtu su originalni Bambu Lab proizvodi. Stanje proizvoda
je oznaceno na sledecu nacin:
</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>
<strong style={{ color: 'var(--text-primary)' }}>Novi proizvodi</strong>
neotvoreno, originalno fabricko pakovanje. Proizvod nije koriscen niti otvaran.
</li>
<li>
<strong style={{ color: 'var(--text-primary)' }}>Korisceni proizvodi</strong>
stanje je opisano za svaki proizvod pojedinacno u opisu artikla.
</li>
</ul>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
4. Cene
</h2>
<p>
Sve cene prikazane na sajtu su informativnog karaktera i izrazene su u srpskim
dinarima (RSD). Cene se mogu promeniti bez prethodne najave. Aktuelna cena u
trenutku kupovine je ona koja je navedena u oglasu na KupujemProdajem platformi.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
5. Garancija
</h2>
<p>
S obzirom da se radi o privatnoj prodaji fizickog lica, na proizvode se ne daje
komercijalna garancija osim onoga sto je zakonski propisano za privatnu prodaju.
Svi proizvodi oznaceni kao novi su u neotvorenom fabrickom pakovanju, sto
potvrdjuje njihov originalni kvalitet.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
6. Nacin kupovine
</h2>
<p>
Sve transakcije se obavljaju iskljucivo putem platforme{' '}
<a
href="https://www.kupujemprodajem.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline underline-offset-4"
>
KupujemProdajem
</a>
. Na sve transakcije obavljene putem te platforme primenjuju se uslovi
koriscenja KupujemProdajem platforme, ukljucujuci njihov sistem zastite kupaca.
Filamenteka ne odgovara za uslove i pravila KupujemProdajem platforme.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
7. Intelektualna svojina
</h2>
<p>
Bambu Lab je zastiteni znak kompanije Bambu Lab. Filamenteka nije u vlasnistvu,
niti je ovlascena od strane, niti je na bilo koji nacin povezana sa kompanijom
Bambu Lab. Svi nazivi proizvoda, logotipi i zastitni znakovi su vlasnistvo
njihovih respektivnih vlasnika.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
8. Ogranicenje odgovornosti
</h2>
<p>
Informacije na ovom sajtu su predstavljene &quot;takve kakve jesu&quot;. Ulazemo
razumne napore da informacije budu tacne i azurne, ali ne garantujemo potpunu
tacnost, kompletnost ili aktuelnost sadrzaja. Filamenteka ne snosi odgovornost
za bilo kakvu stetu nastalu koriscenjem informacija sa ovog sajta.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
9. Izmene uslova koriscenja
</h2>
<p>
Zadrzavamo pravo da azuriramo ove uslove koriscenja u bilo kom trenutku.
Sve izmene ce biti objavljene na ovoj stranici sa azuriranim datumom poslednje
izmene. Nastavak koriscenja sajta nakon objave izmena smatra se prihvatanjem
novih uslova.
</p>
</section>
<section>
<h2
className="text-xl font-bold mb-3"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
10. Kontakt
</h2>
<p>
Za sva pitanja u vezi sa ovim uslovima koriscenja, mozete nas kontaktirati:
</p>
<ul className="list-none space-y-1 mt-2">
<li>Sajt: filamenteka.rs</li>
<li>Telefon: +381 63 103 1048</li>
</ul>
</section>
</div>
</article>
</main>
<SiteFooter />
</div>
);
}

View File

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

View File

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

@@ -1,32 +0,0 @@
-- Migration: Create products table for non-filament product categories
-- This is purely additive - does not modify existing tables
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TYPE product_category AS ENUM ('printer', 'build_plate', 'nozzle', 'spare_part', 'accessory');
CREATE TYPE product_condition AS ENUM ('new', 'used_like_new', 'used_good', 'used_fair');
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category product_category NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
price INTEGER,
condition product_condition DEFAULT 'new',
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
sale_percentage INTEGER DEFAULT 0,
sale_active BOOLEAN DEFAULT FALSE,
sale_start_date TIMESTAMP WITH TIME ZONE,
sale_end_date TIMESTAMP WITH TIME ZONE,
attributes JSONB NOT NULL DEFAULT '{}',
image_url VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_slug ON products(slug);
CREATE INDEX idx_products_is_active ON products(is_active);
CREATE INDEX idx_products_category_active ON products(category, is_active);

View File

@@ -1,25 +0,0 @@
-- Migration: Create printer models and product compatibility junction table
CREATE TABLE printer_models (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
series VARCHAR(50) NOT NULL
);
CREATE TABLE product_printer_compatibility (
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
printer_model_id UUID REFERENCES printer_models(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, printer_model_id)
);
-- Seed printer models
INSERT INTO printer_models (name, series) VALUES
('A1 Mini', 'A'),
('A1', 'A'),
('P1P', 'P'),
('P1S', 'P'),
('X1C', 'X'),
('X1E', 'X'),
('H2D', 'H'),
('H2S', 'H'),
('P2S', 'P');

View File

@@ -1,12 +0,0 @@
-- Migration: Multi-image support for products
CREATE TABLE product_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
alt_text VARCHAR(255),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_product_images_product_id ON product_images(product_id);

1
next-env.d.ts vendored
View File

@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

2487
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "./scripts/kill-dev.sh && next dev",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx --ignore-path .gitignore",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch",
"security:check": "node scripts/security/security-check.js",
@@ -20,31 +20,31 @@
"axios": "^1.6.2",
"bcryptjs": "^3.0.2",
"cheerio": "^1.1.0",
"next": "^16.1.6",
"next": "^15.3.4",
"pg": "^8.16.2",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.24",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"husky": "^9.1.7",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"postcss": "^8.5.6",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"tsx": "^4.20.3",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@@ -1,8 +0,0 @@
User-agent: *
Allow: /
Disallow: /upadaj
Disallow: /upadaj/
Disallow: /dashboard
Disallow: /dashboard/
Sitemap: https://filamenteka.rs/sitemap.xml

View File

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://filamenteka.rs</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://filamenteka.rs/filamenti</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://filamenteka.rs/stampaci</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/ploce</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/mlaznice</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/delovi</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/oprema</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://filamenteka.rs/politika-privatnosti</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://filamenteka.rs/uslovi-koriscenja</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

View File

@@ -1,92 +0,0 @@
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();

View File

@@ -13,8 +13,8 @@ cd /home/ubuntu/filamenteka-api
# Backup current server.js
cp server.js server.js.backup
# Download the updated server.js from Gitea
curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js
# Download the updated server.js from GitHub
curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js
# Restart the service
sudo systemctl restart node-api
@@ -30,7 +30,7 @@ aws ssm send-command \
--parameters "commands=[
'cd /home/ubuntu/filamenteka-api',
'cp server.js server.js.backup',
'curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js',
'curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js',
'sudo systemctl restart node-api',
'sudo systemctl status node-api'
]" \

View File

@@ -1,97 +0,0 @@
#!/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}"

View File

@@ -1,26 +0,0 @@
#!/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

@@ -1,6 +1,6 @@
#!/bin/bash
# Script to update API server via Gitea
# Script to update API server via GitHub
# Since we can't SSH directly, we'll use the API server to pull latest code
echo "🚀 Updating API server with latest code..."

View File

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

View File

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

@@ -1,288 +0,0 @@
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,7 +14,6 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
@@ -38,7 +37,6 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
@@ -138,13 +136,12 @@ 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 *
Email (opciono)
</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"
@@ -153,23 +150,6 @@ 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

@@ -0,0 +1,67 @@
'use client';
import React, { useState } from 'react';
import ColorRequestModal from './ColorRequestModal';
import { trackEvent } from './MatomoAnalytics';
export default function ColorRequestSection() {
const [showModal, setShowModal] = useState(false);
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<div className="text-center">
<div className="flex justify-center mb-6">
<div className="p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<svg className="w-16 h-16 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Zatražite Novu Boju
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
Ne možete pronaći boju koju tražite u našoj ponudi?
Javite nam koju boju želite i mi ćemo je nabaviti za vas!
</p>
<div className="space-y-4">
<button
onClick={() => {
setShowModal(true);
trackEvent('Navigation', 'Request Color', 'Color Request Tab');
}}
className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Pošaljite Zahtev za Boju
</button>
</div>
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">1-3</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za odgovor</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100+</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dostupnih boja</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">7-14</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za nabavku</div>
</div>
</div>
</div>
</div>
<ColorRequestModal isOpen={showModal} onClose={() => setShowModal(false)} />
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,165 +0,0 @@
'use client';
import React from 'react';
import '@/src/styles/select.css';
import { trackEvent } from '@/src/components/MatomoAnalytics';
interface FilterConfigItem {
key: string;
label: string;
type: 'select' | 'range' | 'checkbox';
options?: string[];
}
interface CatalogFiltersProps {
filters: Record<string, string>;
onFilterChange: (filters: Record<string, string>) => void;
filterConfig: FilterConfigItem[];
dynamicOptions?: Record<string, string[]>;
}
export function CatalogFilters({
filters,
onFilterChange,
filterConfig,
dynamicOptions,
}: CatalogFiltersProps) {
const hasActiveFilters = Object.values(filters).some((v) => v !== '');
const getOptions = (config: FilterConfigItem): string[] => {
if (dynamicOptions && dynamicOptions[config.key]) {
return dynamicOptions[config.key];
}
return config.options || [];
};
const handleFilterChange = (key: string, value: string) => {
onFilterChange({ ...filters, [key]: value });
trackEvent('Filter', key, value || 'All');
};
const handleReset = () => {
const cleared: Record<string, string> = {};
filterConfig.forEach((f) => {
cleared[f.key] = '';
});
onFilterChange(cleared);
trackEvent('Filter', 'Reset', 'All Filters');
};
return (
<div
className="rounded-2xl border p-4 sm:p-5"
style={{
borderColor: 'var(--border-subtle)',
background: 'var(--surface-secondary)',
}}
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4 max-w-4xl mx-auto">
{filterConfig.map((config) => {
if (config.type === 'select') {
const options = getOptions(config);
return (
<div key={config.key}>
<label
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
>
{config.label}
</label>
<select
value={filters[config.key] || ''}
onChange={(e) => handleFilterChange(config.key, e.target.value)}
className="custom-select w-full px-3 py-2.5 text-sm font-medium
border rounded-xl
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
transition-all duration-200"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
>
<option value="">Sve</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
if (config.type === 'checkbox') {
return (
<div key={config.key} className="flex items-end">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={filters[config.key] === 'true'}
onChange={(e) =>
handleFilterChange(config.key, e.target.checked ? 'true' : '')
}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600
text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
/>
<span className="text-sm font-medium group-hover:opacity-80 transition-opacity"
style={{ color: 'var(--text-primary)' }}>
{config.label}
</span>
</label>
</div>
);
}
if (config.type === 'range') {
return (
<div key={config.key}>
<label
className="block text-xs font-semibold uppercase tracking-wider mb-1.5"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-secondary)' }}
>
{config.label}
</label>
<input
type="text"
placeholder="npr. 1000-5000"
value={filters[config.key] || ''}
onChange={(e) => handleFilterChange(config.key, e.target.value)}
className="w-full px-3 py-2.5 text-sm border rounded-xl
focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500
transition-all duration-200"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
/>
</div>
);
}
return null;
})}
</div>
{hasActiveFilters && (
<div className="mt-4 text-center">
<button
onClick={handleReset}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold
text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10
hover:bg-red-100 dark:hover:bg-red-500/20
rounded-xl transition-colors duration-200"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
Resetuj filtere
</button>
</div>
)}
</div>
);
}

View File

@@ -1,676 +0,0 @@
'use client';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { Product } from '@/src/types/product';
import { Filament } from '@/src/types/filament';
import { CategoryConfig } from '@/src/config/categories';
import { CONDITION_LABELS } from '@/src/config/categories';
import { filamentService, productService, colorService } from '@/src/services/api';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
import { Badge } from '@/src/components/ui/Badge';
import { EmptyState } from '@/src/components/ui/EmptyState';
import { CatalogFilters } from './CatalogFilters';
import { ProductTable, Column } from './ProductTable';
import { ProductGrid } from './ProductGrid';
import { FilamentRow } from './FilamentRow';
import { FilamentCard } from './FilamentCard';
import '@/src/styles/select.css';
type ViewMode = 'table' | 'grid';
interface CatalogPageProps {
category: CategoryConfig;
seoContent?: React.ReactNode;
}
function CatalogSkeleton() {
return (
<div className="space-y-5 animate-pulse">
{/* Search skeleton */}
<div className="h-12 rounded-2xl" style={{ background: 'var(--surface-secondary)' }} />
{/* Filter skeleton */}
<div className="p-5 rounded-2xl" style={{ background: 'var(--surface-secondary)' }}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-4xl mx-auto">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-10 rounded-xl" style={{ background: 'var(--border-color)' }} />
</div>
))}
</div>
</div>
{/* Table skeleton */}
<div className="rounded-2xl border overflow-hidden" style={{ borderColor: 'var(--border-subtle)' }}>
<div className="h-12" style={{ background: 'var(--surface-secondary)' }} />
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-14 flex items-center px-5 gap-4" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<div className="h-3 w-16 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-20 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-24 rounded-full" style={{ background: 'var(--border-color)' }} />
<div className="h-3 w-12 rounded-full" style={{ background: 'var(--border-color)' }} />
</div>
))}
</div>
</div>
);
}
function ViewToggle({
viewMode,
onToggle,
}: {
viewMode: ViewMode;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="inline-flex items-center gap-2 px-3.5 py-2 text-sm font-semibold rounded-xl
border transition-all duration-200 hover:shadow-sm active:scale-[0.97]"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-secondary)',
}}
title={viewMode === 'table' ? 'Prikazi kao kartice' : 'Prikazi kao tabelu'}
>
{viewMode === 'table' ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
Kartice
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Tabela
</>
)}
</button>
);
}
const FILAMENT_COLUMNS: Column<Filament>[] = [
{
key: 'tip',
label: 'Tip',
sortable: true,
render: (f) => <span className="font-semibold">{f.tip}</span>,
},
{
key: 'finish',
label: 'Finis',
sortable: true,
render: (f) => <span>{f.finish}</span>,
},
{
key: 'boja',
label: 'Boja',
sortable: true,
render: (f) => (
<div className="flex items-center gap-2">
{f.boja_hex && (
<div
className="w-6 h-6 rounded-lg flex-shrink-0 shadow-sm"
style={{
backgroundColor: f.boja_hex,
border: '1px solid rgba(0,0,0,0.1)',
}}
title={f.boja_hex}
/>
)}
<span className="text-xs sm:text-sm font-medium">{f.boja}</span>
</div>
),
},
{
key: 'refill',
label: 'Refil',
sortable: true,
render: (f) =>
f.refill > 0 ? (
<span className="text-emerald-600 dark:text-emerald-400 font-bold">{f.refill}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>0</span>
),
},
{
key: 'spulna',
label: 'Spulna',
sortable: true,
render: (f) =>
f.spulna > 0 ? (
<span className="text-blue-500 dark:text-blue-400 font-bold">{f.spulna}</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>0</span>
),
},
{
key: 'kolicina',
label: 'Kolicina',
sortable: true,
render: (f) => <span className="font-semibold">{f.kolicina}</span>,
},
];
function getProductColumns(): Column<Product>[] {
return [
{
key: 'name',
label: 'Naziv',
sortable: true,
render: (p) => (
<div className="flex items-center gap-3">
{p.image_url && (
<img
src={p.image_url}
alt={p.name}
className="w-10 h-10 rounded-lg object-cover"
loading="lazy"
/>
)}
<span className="font-semibold" style={{ fontFamily: 'var(--font-display)' }}>{p.name}</span>
</div>
),
},
{
key: 'condition',
label: 'Stanje',
sortable: true,
render: (p) => (
<Badge
variant={p.condition === 'new' ? 'success' : 'info'}
size="sm"
>
{CONDITION_LABELS[p.condition] || p.condition}
</Badge>
),
},
{
key: 'stock',
label: 'Na stanju',
sortable: true,
render: (p) =>
p.stock > 0 ? (
<Badge variant="success" size="sm">
{p.stock}
</Badge>
) : (
<Badge variant="danger" size="sm">
Nema
</Badge>
),
},
{
key: 'price',
label: 'Cena',
sortable: true,
render: (p) => (
<PriceDisplay
price={p.price}
saleActive={p.sale_active}
salePercentage={p.sale_percentage}
/>
),
},
];
}
export function CatalogPage({ category, seoContent }: CatalogPageProps) {
const isFilament = category.isFilament === true;
const [filaments, setFilaments] = useState<Filament[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [availableColors, setAvailableColors] = useState<
Array<{ id: string; name: string; hex: string; cena_refill?: number; cena_spulna?: number }>
>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
category.filters.forEach((f) => {
initial[f.key] = '';
});
return initial;
});
const [sortField, setSortField] = useState<string>(isFilament ? 'boja' : 'name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [isDesktop, setIsDesktop] = useState(true);
useEffect(() => {
const checkDesktop = () => {
setIsDesktop(window.innerWidth >= 1024);
};
checkDesktop();
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
}, []);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
if (isFilament) {
const [filamentData, colorData] = await Promise.all([
filamentService.getAll(),
colorService.getAll(),
]);
setFilaments(filamentData);
setAvailableColors(colorData);
} else if (category.apiCategory) {
const data = await productService.getAll({
category: category.apiCategory,
});
setProducts(data);
}
} catch (err) {
console.error('Error fetching catalog data:', err);
setError('Greska pri ucitavanju podataka. Pokusajte ponovo.');
} finally {
setLoading(false);
}
};
fetchData();
}, [isFilament, category.apiCategory]);
const dynamicOptions = useMemo(() => {
if (!isFilament) return undefined;
const filamentsWithInventory = filaments.filter((f) => f.kolicina > 0);
return {
material: [...new Set(filamentsWithInventory.map((f) => f.tip))].sort(),
finish: [...new Set(filamentsWithInventory.map((f) => f.finish))].sort(),
color: [...new Set(filamentsWithInventory.map((f) => f.boja))].sort(),
};
}, [isFilament, filaments]);
const handleSort = useCallback(
(field: string) => {
if (sortField === field) {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortOrder('asc');
}
trackEvent('Table', 'Sort', field);
},
[sortField]
);
const handleViewToggle = useCallback(() => {
const next = viewMode === 'table' ? 'grid' : 'table';
setViewMode(next);
trackEvent('View', 'Toggle', next);
}, [viewMode]);
const effectiveView = isDesktop ? viewMode : 'grid';
const filteredFilaments = useMemo(() => {
if (!isFilament) return [];
let filtered = filaments.filter((f) => {
if (f.kolicina === 0) return false;
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matches =
f.tip.toLowerCase().includes(term) ||
f.finish.toLowerCase().includes(term) ||
f.boja.toLowerCase().includes(term) ||
f.cena.toLowerCase().includes(term);
if (!matches) return false;
}
if (filters.material && f.tip !== filters.material) return false;
if (filters.finish && f.finish !== filters.finish) return false;
if (filters.color && f.boja !== filters.color) return false;
return true;
});
if (sortField) {
filtered.sort((a, b) => {
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
if (sortField === 'kolicina' || sortField === 'cena' || sortField === 'refill' || sortField === 'spulna') {
aVal = parseFloat(String(aVal)) || 0;
bVal = parseFloat(String(bVal)) || 0;
} else {
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [isFilament, filaments, searchTerm, filters, sortField, sortOrder]);
const filteredProducts = useMemo(() => {
if (isFilament) return [];
let filtered = products.filter((p) => {
if (p.is_active === false) return false;
if (searchTerm) {
const term = searchTerm.toLowerCase();
if (!p.name.toLowerCase().includes(term)) return false;
}
if (filters.condition && p.condition !== filters.condition) return false;
return true;
});
if (sortField) {
filtered.sort((a, b) => {
let aVal: string | number = (a as unknown as Record<string, unknown>)[sortField] as string | number;
let bVal: string | number = (b as unknown as Record<string, unknown>)[sortField] as string | number;
if (sortField === 'price' || sortField === 'stock') {
aVal = Number(aVal) || 0;
bVal = Number(bVal) || 0;
} else {
aVal = String(aVal || '').toLowerCase();
bVal = String(bVal || '').toLowerCase();
}
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [isFilament, products, searchTerm, filters, sortField, sortOrder]);
const totalCount = isFilament ? filteredFilaments.length : filteredProducts.length;
const filamentColumnsWithPrice = useMemo((): Column<Filament>[] => {
return [
...FILAMENT_COLUMNS,
{
key: 'cena',
label: 'Cena',
sortable: true,
render: (filament: Filament) => {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
if (!hasRefill && !hasSpool) return <span>-</span>;
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
} else {
const colorData = availableColors.find((c) => c.name === filament.boja);
refillPrice = colorData?.cena_refill || 3499;
spoolPrice = colorData?.cena_spulna || 3999;
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
return (
<span className="font-bold">
{hasRefill && (
<span className={saleActive ? 'line-through text-gray-400' : 'text-emerald-600 dark:text-emerald-400'}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-emerald-600 dark:text-emerald-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1" style={{ color: 'var(--text-muted)' }}>/</span>}
{hasSpool && (
<span className={saleActive ? 'line-through text-gray-400' : 'text-blue-500 dark:text-blue-400'}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 font-normal text-xs" style={{ color: 'var(--text-muted)' }}>RSD</span>
{saleActive && (
<span className="ml-2">
<Badge variant="sale" size="sm">
-{filament.sale_percentage}%
</Badge>
</span>
)}
</span>
);
},
},
];
}, [availableColors]);
const productColumns = useMemo(() => getProductColumns(), []);
const filterConfigWithLabels = useMemo(() => {
return category.filters.map((f) => {
if (f.key === 'condition' && f.options) {
return {
...f,
options: f.options.map((opt) => opt),
};
}
return f;
});
}, [category.filters]);
const conditionDynamicOptions = useMemo(() => {
if (isFilament) return undefined;
const opts: Record<string, string[]> = {};
category.filters.forEach((f) => {
if (f.key === 'condition' && f.options) {
opts[f.key] = f.options;
}
});
return Object.keys(opts).length > 0 ? opts : undefined;
}, [isFilament, category.filters]);
const displayFilterConfig = useMemo(() => {
return category.filters.map((f) => {
if (f.key === 'condition' && f.options) {
return {
...f,
options: f.options.map((opt) => CONDITION_LABELS[opt] || opt),
};
}
return f;
});
}, [category.filters]);
const handleFilterChange = useCallback(
(newFilters: Record<string, string>) => {
const mapped = { ...newFilters };
const conditionFilter = category.filters.find((f) => f.key === 'condition');
if (conditionFilter && mapped.condition) {
const conditionEntries = Object.entries(CONDITION_LABELS);
const entry = conditionEntries.find(([, label]) => label === mapped.condition);
if (entry) {
mapped.condition = entry[0];
}
}
setFilters(mapped);
},
[category.filters]
);
const displayFilters = useMemo(() => {
const mapped = { ...filters };
if (mapped.condition) {
mapped.condition = CONDITION_LABELS[mapped.condition] || mapped.condition;
}
return mapped;
}, [filters]);
if (loading) {
return (
<div className="space-y-6">
<CatalogSkeleton />
</div>
);
}
if (error) {
return (
<div
className="rounded-2xl border p-8 text-center"
style={{
borderColor: 'rgba(239,68,68,0.2)',
background: 'rgba(239,68,68,0.04)',
}}
>
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-red-50 dark:bg-red-500/10 flex items-center justify-center">
<svg className="w-7 h-7 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<p className="font-semibold text-red-700 dark:text-red-300 mb-4" style={{ fontFamily: 'var(--font-display)' }}>
{error}
</p>
<button
onClick={() => window.location.reload()}
className="px-5 py-2.5 text-sm font-bold text-white bg-red-500 hover:bg-red-600
rounded-xl transition-colors duration-200 active:scale-[0.97]"
>
Pokusaj ponovo
</button>
</div>
);
}
return (
<div className="space-y-5">
{/* Search bar */}
<div className="relative">
<input
type="text"
placeholder={isFilament ? 'Pretrazi po materijalu, boji...' : 'Pretrazi proizvode...'}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (e.target.value) {
trackEvent('Search', category.slug, e.target.value);
}
}}
className="w-full px-4 py-3 pl-11 text-sm font-medium border rounded-2xl
focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500
transition-all duration-200 placeholder:font-normal"
style={{
borderColor: 'var(--border-color)',
background: 'var(--surface-elevated)',
color: 'var(--text-primary)',
}}
/>
<svg
className="absolute left-3.5 top-3.5 h-4.5 w-4.5"
style={{ color: 'var(--text-muted)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{/* Filters */}
<CatalogFilters
filters={isFilament ? filters : displayFilters}
onFilterChange={isFilament ? setFilters : handleFilterChange}
filterConfig={isFilament ? category.filters : displayFilterConfig}
dynamicOptions={isFilament ? dynamicOptions : conditionDynamicOptions}
/>
{/* Controls row */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>{totalCount}</span>{' '}
{isFilament ? 'filamenata' : 'proizvoda'}
</p>
<ViewToggle viewMode={effectiveView} onToggle={handleViewToggle} />
</div>
{/* Content */}
{totalCount === 0 ? (
<EmptyState
title="Nema rezultata"
description="Pokusajte da promenite filtere ili pretragu."
/>
) : isFilament ? (
effectiveView === 'table' ? (
<ProductTable<Filament>
items={filteredFilaments}
columns={filamentColumnsWithPrice}
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
) : (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{filteredFilaments.map((filament) => (
<FilamentCard key={filament.id} filament={filament} />
))}
</div>
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
Prikazano {filteredFilaments.length} filamenata
</p>
</div>
)
) : effectiveView === 'table' ? (
<ProductTable<Product>
items={filteredProducts}
columns={productColumns}
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
/>
) : (
<ProductGrid
products={filteredProducts}
categoryColor={category.colorHex}
/>
)}
{/* SEO content slot */}
{seoContent && <div className="mt-8">{seoContent}</div>}
</div>
);
}

View File

@@ -1,188 +0,0 @@
'use client';
import React from 'react';
import { Filament } from '@/src/types/filament';
import { Badge } from '@/src/components/ui/Badge';
interface FilamentCardProps {
filament: Filament;
}
function getContrastColor(hex: string | undefined): 'light' | 'dark' {
if (!hex) return 'dark';
const cleanHex = hex.replace('#', '');
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'dark' : 'light';
}
export function FilamentCard({ filament }: FilamentCardProps) {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
const contrast = getContrastColor(filament.boja_hex);
const isLight = contrast === 'light';
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
const textClass = isLight ? 'text-gray-900' : 'text-white';
const textShadow = isLight ? 'none' : '0 1px 3px rgba(0,0,0,0.5)';
const subtextClass = isLight ? 'text-gray-700' : 'text-gray-100';
return (
<div
className="rounded-2xl overflow-hidden shadow-md
transition-all duration-300 hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1"
style={{
backgroundColor: filament.boja_hex || '#f3f4f6',
border: isLight ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.1)',
}}
>
{/* Top badges */}
<div className="p-4 pb-1">
<div className="flex items-center justify-between">
<Badge
variant={filament.tip === 'PLA' ? 'success' : filament.tip === 'PETG' ? 'info' : 'default'}
size="md"
>
{filament.tip}
</Badge>
{saleActive && (
<Badge variant="sale" size="sm">
-{filament.sale_percentage}%
</Badge>
)}
</div>
<div className={`mt-2 ${subtextClass}`} style={{ textShadow }}>
<span className="text-[11px] font-bold uppercase tracking-[0.12em]">
{filament.finish}
</span>
</div>
</div>
{/* Color name */}
<div className="px-4 py-3">
<h3
className={`text-xl font-black ${textClass}`}
style={{ textShadow, fontFamily: 'var(--font-display)', letterSpacing: '-0.02em' }}
>
{filament.boja}
</h3>
</div>
{/* Bottom info section */}
<div
className="px-4 py-3.5 space-y-2.5"
style={{
backgroundColor: isLight ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
>
{/* Stock counts */}
<div className="flex items-center gap-3">
{hasRefill && (
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-green-700' : 'text-green-300'}`}>
Refil
</span>
<span className={`text-sm font-black ${isLight ? 'text-green-800' : 'text-green-200'}`}>
{filament.refill}
</span>
</div>
)}
{hasSpool && (
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
Spulna
</span>
<span className={`text-sm font-black ${isLight ? 'text-blue-800' : 'text-blue-200'}`}>
{filament.spulna}
</span>
</div>
)}
<div className="flex items-center gap-1.5 ml-auto">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${subtextClass}`} style={{ textShadow }}>
Ukupno
</span>
<span className={`text-sm font-black ${textClass}`} style={{ textShadow }}>
{filament.kolicina}
</span>
</div>
</div>
{/* Pricing */}
<div className={`flex items-center gap-2 flex-wrap pt-2 ${isLight ? 'border-gray-300/60' : 'border-white/15'}`}
style={{ borderTop: `1px solid ${isLight ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.15)'}` }}>
{!hasRefill && !hasSpool ? (
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>-</span>
) : (
<>
{hasRefill && (
<>
{saleActive ? (
<>
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
{refillPrice.toLocaleString('sr-RS')}
</span>
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
</>
) : (
<span className={`text-sm font-black ${isLight ? 'text-green-700' : 'text-green-300'}`}>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
</>
)}
{hasRefill && hasSpool && (
<span className={`text-sm ${subtextClass}`} style={{ textShadow }}>/</span>
)}
{hasSpool && (
<>
{saleActive ? (
<>
<span className={`text-xs line-through ${isLight ? 'text-gray-500' : 'text-gray-300'}`}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
</>
) : (
<span className={`text-sm font-black ${isLight ? 'text-blue-700' : 'text-blue-300'}`}>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
</>
)}
<span className={`text-[11px] font-semibold ${subtextClass} ml-0.5`} style={{ textShadow }}>RSD</span>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,125 +0,0 @@
'use client';
import React from 'react';
import { Filament } from '@/src/types/filament';
interface FilamentRowProps {
filament: Filament;
}
export function FilamentRow({ filament }: FilamentRowProps) {
const hasRefill = filament.refill > 0;
const hasSpool = filament.spulna > 0;
// Parse prices from the cena field (format: "3499" or "3499/3999")
let refillPrice = 3499;
let spoolPrice = 3999;
if (filament.cena) {
const prices = filament.cena.split('/');
if (prices.length === 1) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[0]) || 3999;
} else if (prices.length === 2) {
refillPrice = parseInt(prices[0]) || 3499;
spoolPrice = parseInt(prices[1]) || 3999;
}
}
const saleActive = filament.sale_active && filament.sale_percentage;
const saleRefillPrice = saleActive
? Math.round(refillPrice * (1 - filament.sale_percentage! / 100))
: refillPrice;
const saleSpoolPrice = saleActive
? Math.round(spoolPrice * (1 - filament.sale_percentage! / 100))
: spoolPrice;
return (
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.tip}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.finish}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-1 sm:gap-2">
{filament.boja_hex && (
<div
className="w-5 h-5 sm:w-7 sm:h-7 rounded border border-gray-300 dark:border-gray-600 flex-shrink-0"
style={{ backgroundColor: filament.boja_hex }}
title={filament.boja_hex}
/>
)}
<span className="text-xs sm:text-sm text-gray-900 dark:text-gray-100">
{filament.boja}
</span>
</div>
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
{hasRefill ? (
<span className="text-green-600 dark:text-green-400 font-bold">{filament.refill}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm">
{hasSpool ? (
<span className="text-blue-500 dark:text-blue-400 font-bold">{filament.spulna}</span>
) : (
<span className="text-gray-400 dark:text-gray-500">0</span>
)}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{filament.kolicina}
</td>
<td className="px-2 sm:px-6 py-4 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">
{!hasRefill && !hasSpool ? (
'-'
) : (
<>
{hasRefill && (
<span
className={
saleActive
? 'line-through text-gray-500 dark:text-gray-400'
: 'text-green-600 dark:text-green-400'
}
>
{refillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && saleActive && (
<span className="text-green-600 dark:text-green-400 font-bold ml-1">
{saleRefillPrice.toLocaleString('sr-RS')}
</span>
)}
{hasRefill && hasSpool && <span className="mx-1">/</span>}
{hasSpool && (
<span
className={
saleActive
? 'line-through text-gray-500 dark:text-gray-400'
: 'text-blue-500 dark:text-blue-400'
}
>
{spoolPrice.toLocaleString('sr-RS')}
</span>
)}
{hasSpool && saleActive && (
<span className="text-blue-500 dark:text-blue-400 font-bold ml-1">
{saleSpoolPrice.toLocaleString('sr-RS')}
</span>
)}
<span className="ml-1 text-gray-600 dark:text-gray-400">RSD</span>
{saleActive && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
-{filament.sale_percentage}%
</span>
)}
</>
)}
</td>
</tr>
);
}

View File

@@ -1,118 +0,0 @@
'use client';
import React from 'react';
import { Product } from '@/src/types/product';
import { PriceDisplay } from '@/src/components/ui/PriceDisplay';
import { Badge } from '@/src/components/ui/Badge';
import { CONDITION_LABELS } from '@/src/config/categories';
interface ProductCardProps {
product: Product;
categoryColor?: string;
}
function getCategoryIcon(category: string): React.ReactNode {
switch (category) {
case 'printer':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
);
case 'build_plate':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6z" />
</svg>
);
case 'nozzle':
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
default:
return (
<svg className="w-12 h-12" style={{ color: 'var(--text-muted)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
);
}
}
export function ProductCard({ product, categoryColor = '#3b82f6' }: ProductCardProps) {
const isOutOfStock = product.stock === 0;
const conditionLabel = CONDITION_LABELS[product.condition] || product.condition;
return (
<div
className="category-card rounded-2xl border overflow-hidden"
style={{
'--card-color': categoryColor,
borderColor: 'var(--border-subtle)',
background: 'var(--surface-elevated)',
} as React.CSSProperties}
>
{/* Category accent bar */}
<div className="h-1" style={{ background: `linear-gradient(90deg, ${categoryColor}, ${categoryColor}80)` }} />
{/* Image or placeholder */}
<div className="relative aspect-[4/3] flex items-center justify-center" style={{ background: 'var(--surface-secondary)' }}>
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
getCategoryIcon(product.category)
)}
{isOutOfStock && (
<div className="absolute top-3 right-3">
<Badge variant="danger" size="sm">Nema na stanju</Badge>
</div>
)}
</div>
{/* Content */}
<div className="p-4 sm:p-5 space-y-3">
<h3
className="font-bold line-clamp-2 min-h-[2.5rem]"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
{product.name}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={product.condition === 'new' ? 'success' : 'info'}
size="sm"
>
{conditionLabel}
</Badge>
{!isOutOfStock && (
<Badge variant="success" size="sm">
Na stanju ({product.stock})
</Badge>
)}
</div>
<div className="pt-2" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<PriceDisplay
price={product.price}
saleActive={product.sale_active}
salePercentage={product.sale_percentage}
className="font-bold"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
'use client';
import React from 'react';
import { Product } from '@/src/types/product';
import { ProductCard } from './ProductCard';
interface ProductGridProps {
products: Product[];
categoryColor?: string;
renderCard?: (product: Product) => React.ReactNode;
}
export function ProductGrid({
products,
categoryColor,
renderCard,
}: ProductGridProps) {
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{products.map((product) =>
renderCard ? (
<React.Fragment key={product.id}>{renderCard(product)}</React.Fragment>
) : (
<ProductCard key={product.id} product={product} categoryColor={categoryColor} />
)
)}
</div>
<p className="text-sm text-center mt-5" style={{ color: 'var(--text-muted)' }}>
Prikazano {products.length} proizvoda
</p>
</div>
);
}

View File

@@ -1,94 +0,0 @@
'use client';
import React from 'react';
export interface Column<T> {
key: string;
label: string;
sortable?: boolean;
render?: (item: T) => React.ReactNode;
className?: string;
}
interface ProductTableProps<T> {
items: T[];
columns: Column<T>[];
sortField: string;
sortOrder: 'asc' | 'desc';
onSort: (field: string) => void;
}
function SortIndicator({ field, sortField, sortOrder }: { field: string; sortField: string; sortOrder: 'asc' | 'desc' }) {
if (field !== sortField) {
return (
<span className="ml-1.5 opacity-0 group-hover:opacity-60 transition-opacity text-[10px]">
</span>
);
}
return (
<span className="ml-1.5 text-blue-500 dark:text-blue-400 text-[10px]">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
);
}
export function ProductTable<T extends { id?: string }>({
items,
columns,
sortField,
sortOrder,
onSort,
}: ProductTableProps<T>) {
return (
<div
className="overflow-x-auto rounded-2xl border shadow-sm"
style={{ borderColor: 'var(--border-subtle)' }}
>
<table className="min-w-full">
<thead>
<tr style={{ background: 'var(--surface-secondary)' }}>
{columns.map((col) => (
<th
key={col.key}
onClick={col.sortable !== false ? () => onSort(col.key) : undefined}
className={`px-3 sm:px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.1em] group
${col.sortable !== false ? 'cursor-pointer select-none' : ''}
${col.className || ''}`}
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-muted)' }}
>
<span className="inline-flex items-center">
{col.label}
{col.sortable !== false && (
<SortIndicator field={col.key} sortField={sortField} sortOrder={sortOrder} />
)}
</span>
</th>
))}
</tr>
</thead>
<tbody style={{ background: 'var(--surface-elevated)' }}>
{items.map((item, index) => (
<tr
key={item.id || index}
className="transition-colors duration-150 hover:bg-blue-50/50 dark:hover:bg-white/[0.03]"
style={{ borderTop: '1px solid var(--border-subtle)' }}
>
{columns.map((col) => (
<td
key={col.key}
className={`px-3 sm:px-5 py-3.5 whitespace-nowrap text-sm ${col.className || ''}`}
style={{ color: 'var(--text-primary)' }}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -1,51 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useAuth } from '@/src/hooks/useAuth';
import { AdminSidebar } from './AdminSidebar';
interface AdminLayoutProps {
children: React.ReactNode;
currentPath: string;
}
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
const { isAuthenticated, isLoading } = useAuth();
// Force dark mode for admin
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
if (isLoading) {
return (
<div className="min-h-screen bg-[#060a14] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-white/40 text-sm" style={{ fontFamily: 'var(--font-body)' }}>
Ucitavanje...
</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-[#060a14] flex">
<AdminSidebar currentPath={currentPath} />
<main className="flex-1 min-w-0 lg:ml-0">
{/* Top bar for mobile spacing (hamburger menu space) */}
<div className="lg:hidden h-14" />
<div className="p-4 sm:p-6 lg:p-8">
{children}
</div>
</main>
</div>
);
}

View File

@@ -1,286 +0,0 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { trackEvent } from '@/src/components/MatomoAnalytics';
interface AdminSidebarProps {
currentPath: string;
}
interface SidebarLink {
href: string;
label: string;
icon: React.ReactNode;
colorHex: string;
}
const sidebarLinks: SidebarLink[] = [
{
href: '/upadaj/dashboard',
label: 'Pregled',
colorHex: '#3b82f6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
href: '/upadaj/dashboard/filamenti',
label: 'Filamenti',
colorHex: '#3b82f6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
),
},
{
href: '/upadaj/dashboard/stampaci',
label: 'Stampaci',
colorHex: '#ef4444',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
),
},
{
href: '/upadaj/dashboard/ploce',
label: 'Ploce',
colorHex: '#22c55e',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
),
},
{
href: '/upadaj/dashboard/mlaznice',
label: 'Mlaznice',
colorHex: '#f59e0b',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/delovi',
label: 'Delovi',
colorHex: '#a855f7',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/oprema',
label: 'Oprema',
colorHex: '#06b6d4',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
href: '/upadaj/dashboard/boje',
label: 'Boje',
colorHex: '#ec4899',
icon: (
<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>
),
},
{
href: '/upadaj/dashboard/zahtevi',
label: 'Zahtevi',
colorHex: '#8b5cf6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
),
},
{
href: '/upadaj/dashboard/prodaja',
label: 'Prodaja',
colorHex: '#f97316',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
href: '/upadaj/dashboard/analitika',
label: 'Analitika',
colorHex: '#14b8a6',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
];
export function AdminSidebar({ currentPath }: AdminSidebarProps) {
const [isOpen, setIsOpen] = useState(false);
const isActive = (href: string) => {
if (href === '/upadaj/dashboard' && currentPath === '/upadaj/dashboard') return true;
if (href !== '/upadaj/dashboard' && currentPath.startsWith(href)) return true;
return false;
};
const handleLogout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
trackEvent('Admin', 'Logout', 'Sidebar');
window.location.href = '/upadaj';
};
const sidebarContent = (
<div className="flex flex-col h-full">
{/* Logo */}
<div className="p-4 border-b border-white/[0.08]">
<Link href="/upadaj/dashboard" className="flex items-center gap-3">
<img
src="/logo.png"
alt="Filamenteka Admin"
loading="lazy"
decoding="async"
className="h-10 w-auto"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<div className="flex items-baseline gap-1.5">
<span
className="text-lg font-black tracking-tight text-white"
style={{ fontFamily: 'var(--font-display)' }}
>
Filament<span className="gradient-text">eka</span>
</span>
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-white/40">Admin</span>
</div>
</Link>
</div>
{/* Navigation links */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1" aria-label="Admin navigacija">
{sidebarLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={() => {
trackEvent('Admin', 'Sidebar Navigation', link.label);
setIsOpen(false);
}}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold
transition-all duration-200
${
active
? 'text-white shadow-lg'
: 'text-white/50 hover:text-white hover:bg-white/[0.06]'
}
`}
style={
active
? {
backgroundColor: link.colorHex,
boxShadow: `0 4px 12px ${link.colorHex}40`,
}
: undefined
}
>
{link.icon}
{link.label}
</Link>
);
})}
</nav>
{/* Bottom section */}
<div className="p-3 border-t border-white/[0.08] space-y-1">
{/* Back to site */}
<Link
href="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-white/50 hover:text-white hover:bg-white/[0.06] transition-all duration-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Nazad na sajt
</Link>
{/* Logout */}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-red-400/80 hover:text-red-400 hover:bg-red-500/10 transition-all duration-200 w-full text-left"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Odjava
</button>
</div>
</div>
);
return (
<>
{/* Mobile hamburger button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="lg:hidden fixed top-4 left-4 z-50 p-2.5 bg-slate-900 text-white rounded-xl shadow-lg hover:bg-slate-800 transition-colors border border-white/[0.08]"
aria-label={isOpen ? 'Zatvori meni' : 'Otvori meni'}
aria-expanded={isOpen}
>
{isOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
{/* Mobile overlay */}
{isOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 z-40 h-full w-64 bg-[#0a0e1a] border-r border-white/[0.06]
transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:static lg:z-auto
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
aria-label="Admin bocna navigacija"
>
{sidebarContent}
</aside>
</>
);
}

View File

@@ -1,84 +0,0 @@
'use client';
import Link from 'next/link';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items }: BreadcrumbProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.label,
...(item.href
? {
item: `https://filamenteka.rs${item.href}`,
}
: {}),
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav aria-label="Breadcrumb" className="py-3">
<ol className="flex items-center flex-wrap gap-1 text-sm" style={{ color: 'var(--text-muted)' }}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="flex items-center">
{index > 0 && (
<svg
className="w-3.5 h-3.5 mx-1.5 flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
{isLast || !item.href ? (
<span
className={isLast ? 'font-semibold' : ''}
style={{ color: isLast ? 'var(--text-primary)' : 'var(--text-muted)' }}
aria-current={isLast ? 'page' : undefined}
>
{item.label}
</span>
) : (
<Link
href={item.href}
className="hover:underline underline-offset-4 transition-colors duration-200"
style={{ color: 'var(--text-secondary)' }}
>
{item.label}
</Link>
)}
</li>
);
})}
</ol>
</nav>
</>
);
}

View File

@@ -1,85 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getEnabledCategories, CategoryConfig } from '@/src/config/categories';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
import { trackEvent } from '@/src/components/MatomoAnalytics';
// Darker color variants for readable text on light backgrounds
const darkTextMap: Record<string, string> = {
'#3b82f6': '#1d4ed8',
'#ef4444': '#b91c1c',
'#22c55e': '#15803d',
'#f59e0b': '#b45309',
'#a855f7': '#7e22ce',
'#06b6d4': '#0e7490',
};
interface CategoryNavProps {
currentSlug?: string;
}
export function CategoryNav({ currentSlug }: CategoryNavProps) {
const categories = getEnabledCategories();
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const check = () => setIsDark(document.documentElement.classList.contains('dark'));
check();
const observer = new MutationObserver(check);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return (
<nav
className="flex gap-2 overflow-x-auto scrollbar-hide py-1 px-0.5"
aria-label="Kategorije proizvoda"
>
{categories.map((category: CategoryConfig) => {
const isActive = currentSlug === category.slug;
const textColor = isDark ? category.colorHex : (darkTextMap[category.colorHex] || category.colorHex);
return (
<Link
key={category.slug}
href={`/${category.slug}`}
onClick={() => trackEvent('Navigation', 'Category Click', category.label)}
className={`
flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-bold
transition-all duration-250 whitespace-nowrap
${isActive
? 'text-white shadow-lg scale-[1.02]'
: 'hover:scale-[1.01]'
}
`}
style={isActive ? {
backgroundColor: category.colorHex,
boxShadow: `0 4px 20px -2px ${category.colorHex}60`,
fontFamily: 'var(--font-display)',
} : {
backgroundColor: `${category.colorHex}${isDark ? '12' : '10'}`,
color: textColor,
fontFamily: 'var(--font-display)',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '20' : '18'}`;
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = `${category.colorHex}${isDark ? '12' : '10'}`;
}
}}
aria-current={isActive ? 'page' : undefined}
>
<CategoryIcon slug={category.slug} className="w-4 h-4" />
<span>{category.labelShort}</span>
</Link>
);
})}
</nav>
);
}

View File

@@ -1,154 +0,0 @@
'use client';
import Link from 'next/link';
import { trackEvent } from '@/src/components/MatomoAnalytics';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
export function SiteFooter() {
return (
<footer className="relative mt-20">
{/* Bold category color accent strip */}
<div
className="h-1.5"
style={{
background: 'linear-gradient(90deg, #3b82f6 0%, #6366f1 10%, #ef4444 20%, #f97316 30%, #22c55e 42%, #10b981 52%, #f59e0b 62%, #a855f7 74%, #ec4899 84%, #06b6d4 92%, #3b82f6 100%)',
}}
/>
<div className="bg-gradient-to-b from-slate-900 to-slate-950 dark:from-[#0f172a] dark:to-[#0a0f1e]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-12 pb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-10 lg:gap-14">
{/* Brand */}
<div>
<Link href="/" className="inline-flex items-center gap-2.5 group mb-5">
<img
src="/logo.png"
alt="Filamenteka"
loading="lazy"
decoding="async"
className="h-9 w-auto opacity-85 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<span className="text-lg font-bold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
<span className="text-white">Filament</span>
<span className="gradient-text">eka</span>
</span>
</Link>
<p className="text-sm text-gray-300/80 leading-relaxed max-w-xs">
Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, stampaci, podloge, mlaznice i rezervni delovi.
</p>
</div>
{/* Links */}
<div>
<h3
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
style={{ fontFamily: 'var(--font-display)' }}
>
Navigacija
</h3>
<ul className="space-y-3">
<li>
<Link
href="/filamenti"
className="text-sm text-gray-400 hover:text-blue-400 transition-colors duration-300 font-medium"
>
Filamenti
</Link>
</li>
<li>
<Link
href="/stampaci"
className="text-sm text-gray-400 hover:text-red-400 transition-colors duration-300 font-medium"
>
3D Stampaci
</Link>
</li>
<li>
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer')}
className="text-sm text-gray-400 hover:text-emerald-400 transition-colors duration-300 inline-flex items-center gap-1.5 font-medium"
>
KupujemProdajem
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</li>
<li className="pt-2 border-t border-white/[0.08]">
<Link
href="/politika-privatnosti"
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
>
Politika privatnosti
</Link>
</li>
<li>
<Link
href="/uslovi-koriscenja"
className="text-sm text-gray-500 hover:text-gray-300 transition-colors duration-300"
>
Uslovi koriscenja
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3
className="text-xs font-bold text-gray-300 uppercase tracking-[0.15em] mb-5"
style={{ fontFamily: 'var(--font-display)' }}
>
Kontakt
</h3>
<div className="space-y-4">
<a
href="tel:+381631031048"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
className="flex items-center gap-3 text-gray-400 hover:text-blue-300 transition-all duration-300 group"
>
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-blue-500/20 group-hover:shadow-[0_0_12px_rgba(59,130,246,0.15)] transition-all duration-300">
<svg className="w-[18px] h-[18px]" 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>
</div>
<span className="text-sm font-medium">+381 63 103 1048</span>
</a>
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Footer Contact')}
className="flex items-center gap-3 text-gray-400 hover:text-emerald-300 transition-all duration-300 group"
>
<div className="w-10 h-10 rounded-lg bg-white/[0.06] flex items-center justify-center group-hover:bg-emerald-500/20 group-hover:shadow-[0_0_12px_rgba(16,185,129,0.15)] transition-all duration-300">
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
</div>
<span className="text-sm font-medium">KupujemProdajem profil</span>
</a>
</div>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 pt-6 border-t border-white/[0.08] flex flex-col sm:flex-row items-center justify-between gap-3">
<p className="text-xs text-gray-400">
&copy; {new Date().getFullYear()} <span className="font-semibold text-gray-300">Filamenteka</span>. Sva prava zadrzana.
</p>
<p className="text-xs text-gray-500 text-center sm:text-right max-w-sm">
Privatna prodaja fizickog lica, ne registrovana prodavnica.
</p>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -1,238 +0,0 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useRef } from 'react';
import { getEnabledCategories } from '@/src/config/categories';
import { trackEvent } from '@/src/components/MatomoAnalytics';
import { CategoryNav } from './CategoryNav';
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
interface SiteHeaderProps {
currentCategory?: string;
}
export function SiteHeader({ currentCategory }: SiteHeaderProps) {
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const categories = getEnabledCategories();
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved) setDarkMode(JSON.parse(saved));
}, []);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
// Compact header on scroll
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Close mobile menu on outside click
useEffect(() => {
if (!mobileMenuOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMobileMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [mobileMenuOpen]);
const handleMobileLinkClick = () => setMobileMenuOpen(false);
const handleDarkModeToggle = () => {
setDarkMode(!darkMode);
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
};
return (
<header
className={`sticky top-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-white/95 dark:bg-[#0f172a]/90 backdrop-blur-xl shadow-lg shadow-black/[0.06] dark:shadow-black/30'
: 'bg-white dark:bg-[#0f172a]'
} border-b border-gray-200 dark:border-white/[0.08]`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className={`flex items-center justify-between transition-all duration-300 ${scrolled ? 'h-12 sm:h-14' : 'h-14 sm:h-16'}`}>
{/* Logo */}
<Link
href="/"
className="flex-shrink-0 flex items-center gap-2.5 group"
aria-label="Filamenteka - Pocetna"
>
<img
src="/logo.png"
alt="Filamenteka"
loading="lazy"
decoding="async"
className={`transition-all duration-300 ${scrolled ? 'h-7 sm:h-8' : 'h-8 sm:h-10'} w-auto group-hover:brightness-110 group-hover:scale-105`}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
<span className="hidden sm:inline text-xl font-extrabold tracking-tight" style={{ fontFamily: 'var(--font-display)' }}>
<span className="text-gray-900 dark:text-white">Filament</span>
<span className="gradient-text">eka</span>
</span>
</Link>
{/* Desktop: Category Nav */}
<div className="hidden md:block flex-1 mx-6 overflow-x-auto scrollbar-hide">
<CategoryNav currentSlug={currentCategory} />
</div>
{/* Right side actions */}
<div className="flex-shrink-0 flex items-center gap-2 sm:gap-2.5">
{/* KP link — desktop */}
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Header')}
className="hidden sm:inline-flex items-center gap-1.5 px-4 py-1.5
bg-gradient-to-r from-emerald-500 to-teal-500
hover:from-emerald-400 hover:to-teal-400
text-white text-sm font-bold rounded-full
shadow-md shadow-emerald-500/30 hover:shadow-lg hover:shadow-teal-500/40
transition-all duration-200 active:scale-95 hover:scale-[1.03]"
title="KupujemProdajem"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
KP
</a>
{/* Dark mode toggle */}
{mounted ? (
<button
onClick={handleDarkModeToggle}
className="relative p-2.5 rounded-full text-gray-500 dark:text-gray-300
hover:text-amber-500 dark:hover:text-amber-400
hover:bg-amber-50 dark:hover:bg-amber-500/10
transition-all duration-200 hover:scale-110"
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
aria-label={darkMode ? 'Prebaci na svetlu temu' : 'Prebaci na tamnu temu'}
>
{darkMode ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)}
</button>
) : (
<div className="w-10 h-10" aria-hidden="true" />
)}
{/* Hamburger — mobile */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-xl text-gray-700 dark:text-gray-200
hover:bg-gray-100 dark:hover:bg-white/10
transition-all duration-200"
aria-label={mobileMenuOpen ? 'Zatvori meni' : 'Otvori meni'}
aria-expanded={mobileMenuOpen}
>
<div className="w-6 h-5 flex flex-col justify-between relative">
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? 'rotate-45 translate-y-[9px]' : ''}`} />
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-200 ${mobileMenuOpen ? 'opacity-0 scale-0' : ''}`} />
<span className={`block h-[3px] w-6 bg-current rounded-full transition-all duration-300 origin-center ${mobileMenuOpen ? '-rotate-45 -translate-y-[9px]' : ''}`} />
</div>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div
ref={menuRef}
className={`md:hidden overflow-hidden transition-all duration-300 ease-in-out ${
mobileMenuOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="border-t border-gray-200/50 dark:border-white/[0.08]">
<nav className="max-w-7xl mx-auto px-4 py-3 space-y-1" aria-label="Kategorije proizvoda">
{categories.map((cat) => {
const isActive = currentCategory === cat.slug;
return (
<Link
key={cat.slug}
href={`/${cat.slug}`}
onClick={handleMobileLinkClick}
className={`flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-semibold transition-all duration-200 ${
isActive
? 'text-white shadow-lg scale-[1.01]'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.06] hover:scale-[1.01]'
}`}
style={isActive ? {
backgroundColor: cat.colorHex,
boxShadow: `0 6px 20px ${cat.colorHex}50`,
} : undefined}
>
{/* Colored dot indicator */}
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-white/60' : ''}`}
style={!isActive ? { backgroundColor: cat.colorHex } : undefined}
/>
<CategoryIcon slug={cat.slug} className="w-5 h-5" />
<span className="font-bold">{cat.label}</span>
{isActive && (
<svg className="w-4 h-4 ml-auto" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</Link>
);
})}
{/* KP link in mobile menu */}
<a
href={KP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent('External Link', 'Kupujem Prodajem', 'Mobile Menu');
handleMobileLinkClick();
}}
className="flex items-center gap-3 px-4 py-3.5 rounded-xl text-sm font-bold
text-emerald-600 dark:text-emerald-400
hover:bg-emerald-50 dark:hover:bg-emerald-900/20
transition-all duration-200 hover:scale-[1.01]"
>
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-emerald-500" />
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>
<span>KupujemProdajem</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-50" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</nav>
</div>
</div>
</header>
);
}

View File

@@ -1,62 +0,0 @@
'use client';
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'sale';
size?: 'sm' | 'md';
}
const variantStyles: Record<NonNullable<BadgeProps['variant']>, string> = {
default:
'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-300',
success:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
warning:
'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
danger:
'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-300',
info:
'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',
sale:
'bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-500/20',
};
const sizeStyles: Record<NonNullable<BadgeProps['size']>, string> = {
sm: 'px-2 py-0.5 text-[11px]',
md: 'px-2.5 py-1 text-xs',
};
export function Badge({
children,
variant = 'default',
size = 'sm',
}: BadgeProps) {
return (
<span
className={`
inline-flex items-center rounded-full font-semibold whitespace-nowrap tracking-wide
${variantStyles[variant]}
${sizeStyles[size]}
`}
>
{variant === 'sale' && (
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
)}
{children}
</span>
);
}

View File

@@ -1,76 +0,0 @@
'use client';
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const variantStyles: Record<NonNullable<ButtonProps['variant']>, string> = {
primary:
'bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 focus:ring-blue-500 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800',
secondary:
'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
danger:
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-800',
ghost:
'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400 dark:text-gray-300 dark:hover:bg-gray-800',
};
const sizeStyles: Record<NonNullable<ButtonProps['size']>, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export function Button({
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
className = '',
children,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || isLoading}
className={`
inline-flex items-center justify-center gap-2 rounded-md font-medium
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{isLoading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
);
}

View File

@@ -1,44 +0,0 @@
'use client';
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
onClick?: () => void;
}
export function Card({
children,
className = '',
hover = false,
onClick,
}: CardProps) {
return (
<div
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
className={`
bg-white dark:bg-gray-800 rounded-lg shadow-md
border border-gray-200 dark:border-gray-700
${hover ? 'transition-all duration-200 hover:shadow-lg hover:scale-[1.02] cursor-pointer' : ''}
${onClick ? 'cursor-pointer' : ''}
${className}
`}
>
{children}
</div>
);
}

View File

@@ -1,76 +0,0 @@
'use client';
interface CategoryIconProps {
slug: string;
className?: string;
}
export function CategoryIcon({ slug, className = 'w-6 h-6' }: CategoryIconProps) {
const svgProps = {
className,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
switch (slug) {
case 'filamenti':
// Spool / reel
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3" />
<line x1="12" y1="3" x2="12" y2="9" />
<line x1="12" y1="15" x2="12" y2="21" />
</svg>
);
case 'stampaci':
// 3D cube
return (
<svg {...svgProps}>
<path d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
);
case 'ploce':
// Build plate with alignment marks
return (
<svg {...svgProps}>
<rect x="3" y="8" width="18" height="8" rx="1.5" />
<path d="M7 8V5.5M12 8V4.5M17 8V5.5" />
</svg>
);
case 'mlaznice':
// Nozzle / hotend
return (
<svg {...svgProps}>
<path d="M8 3h8v4l-2 7h-4L8 7V3Z" />
<path d="M10 14v3M14 14v3" />
<circle cx="12" cy="20" r="1.5" />
</svg>
);
case 'delovi':
// Gear / settings
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3m0 14v3M2 12h3m14 0h3M4.93 4.93l2.12 2.12m9.9 9.9 2.12 2.12M4.93 19.07l2.12-2.12m9.9-9.9 2.12-2.12" />
</svg>
);
case 'oprema':
// Wrench
return (
<svg {...svgProps}>
<path d="M21.75 6.75a4.5 4.5 0 0 1-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 1 1-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 0 1 6.336-4.486l-3.276 3.276a3.004 3.004 0 0 0 2.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852Z" />
</svg>
);
default:
return (
<svg {...svgProps}>
<circle cx="12" cy="12" r="9" />
</svg>
);
}
}

View File

@@ -1,50 +0,0 @@
'use client';
import React from 'react';
interface EmptyStateProps {
title: string;
description?: string;
icon?: React.ReactNode;
}
const defaultIcon = (
<svg
className="w-14 h-14 text-gray-300 dark:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
);
export function EmptyState({
title,
description,
icon,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
<div className="mb-5 p-4 rounded-2xl bg-gray-100/80 dark:bg-white/[0.04]">
{icon || defaultIcon}
</div>
<h3
className="text-lg font-bold mb-2"
style={{ fontFamily: 'var(--font-display)', color: 'var(--text-primary)' }}
>
{title}
</h3>
{description && (
<p className="text-sm max-w-sm" style={{ color: 'var(--text-muted)' }}>
{description}
</p>
)}
</div>
);
}

View File

@@ -1,104 +0,0 @@
'use client';
import React, { useEffect, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}
const sizeStyles: Record<NonNullable<ModalProps['size']>, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
};
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
{/* 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
w-full ${sizeStyles[size]} p-6
border border-gray-200 dark:border-gray-700
`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
{title && (
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
{title}
</h2>
)}
<button
onClick={onClose}
className="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors rounded-md p-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Zatvori"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Content */}
{children}
</div>
</div>
</div>
</>
);
}

View File

@@ -1,52 +0,0 @@
'use client';
import React from 'react';
import { Badge } from './Badge';
interface PriceDisplayProps {
price: number;
saleActive?: boolean;
salePercentage?: number;
className?: string;
}
function formatPrice(price: number): string {
return price.toLocaleString('sr-RS');
}
function calculateSalePrice(price: number, percentage: number): number {
return Math.round(price * (1 - percentage / 100));
}
export function PriceDisplay({
price,
saleActive = false,
salePercentage = 0,
className = '',
}: PriceDisplayProps) {
const salePrice = saleActive && salePercentage > 0
? calculateSalePrice(price, salePercentage)
: price;
if (saleActive && salePercentage > 0) {
return (
<div className={`inline-flex items-center gap-2 ${className}`}>
<span className="line-through text-sm" style={{ color: 'var(--text-muted)' }}>
{formatPrice(price)} RSD
</span>
<span className="font-bold text-emerald-600 dark:text-emerald-400">
{formatPrice(salePrice)} RSD
</span>
<Badge variant="sale" size="sm">
-{salePercentage}%
</Badge>
</div>
);
}
return (
<span className={`font-semibold ${className}`} style={{ color: 'var(--text-primary)' }}>
{formatPrice(price)} <span className="text-sm font-normal" style={{ color: 'var(--text-muted)' }}>RSD</span>
</span>
);
}

View File

@@ -1,148 +0,0 @@
import { ProductCategory } from '@/src/types/product';
export interface CategoryFilter {
key: string;
label: string;
type: 'select' | 'range' | 'checkbox';
options?: string[];
}
export interface CategoryConfig {
slug: string;
label: string;
labelShort: string;
labelEn: string;
apiCategory?: ProductCategory;
color: string;
colorHex: string;
icon: string;
description: string;
metaTitle: string;
metaDescription: string;
filters: CategoryFilter[];
enabled: boolean;
isFilament?: boolean;
}
export const CATEGORIES: CategoryConfig[] = [
{
slug: 'filamenti',
label: 'Filamenti',
labelShort: 'Filamenti',
labelEn: 'Filaments',
color: 'blue',
colorHex: '#3b82f6',
icon: '🧵',
description: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU i mnogi drugi materijali.',
metaTitle: 'Bambu Lab Filamenti | PLA, PETG, ABS - Filamenteka',
metaDescription: 'Originalni Bambu Lab filamenti za 3D stampac. PLA, PETG, ABS, TPU. Privatna prodaja u Srbiji.',
filters: [
{ key: 'material', label: 'Materijal', type: 'select', options: ['ABS', 'ASA', 'PA6', 'PAHT', 'PC', 'PET', 'PETG', 'PLA', 'PPA', 'PPS', 'TPU'] },
{ key: 'finish', label: 'Finish', type: 'select' },
{ key: 'color', label: 'Boja', type: 'select' },
],
enabled: true,
isFilament: true,
},
{
slug: 'stampaci',
label: '3D Stampaci',
labelShort: 'Stampaci',
labelEn: '3D Printers',
apiCategory: 'printer',
color: 'red',
colorHex: '#ef4444',
icon: '🖨️',
description: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
metaTitle: 'Bambu Lab 3D Stampaci | A1, P1S, X1C - Filamenteka',
metaDescription: 'Bambu Lab 3D stampaci - A1 Mini, A1, P1S, X1C. Novi i polovni. Privatna prodaja u Srbiji.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
],
enabled: true,
},
{
slug: 'ploce',
label: 'Build Plate (Podloge)',
labelShort: 'Podloge',
labelEn: 'Build Plates',
apiCategory: 'build_plate',
color: 'green',
colorHex: '#22c55e',
icon: '📐',
description: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
metaTitle: 'Bambu Lab Build Plate (Podloga) - Filamenteka',
metaDescription: 'Bambu Lab build plate podloge - Cool Plate, Engineering Plate, High Temp Plate, Textured PEI.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'mlaznice',
label: 'Mlaznice i Hotend (Nozzles)',
labelShort: 'Mlaznice',
labelEn: 'Nozzles & Hotend',
apiCategory: 'nozzle',
color: 'amber',
colorHex: '#f59e0b',
icon: '🔩',
description: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
metaTitle: 'Bambu Lab Nozzle i Hotend (Mlaznice/Dizne) - Filamenteka',
metaDescription: 'Bambu Lab nozzle mlaznice i hotend za A1, P1, X1. Hardened steel, stainless steel dizne.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'delovi',
label: 'Rezervni Delovi (Spare Parts)',
labelShort: 'Delovi',
labelEn: 'Spare Parts',
apiCategory: 'spare_part',
color: 'purple',
colorHex: '#a855f7',
icon: '🔧',
description: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
metaTitle: 'Bambu Lab Rezervni Delovi (Spare Parts) - Filamenteka',
metaDescription: 'Bambu Lab rezervni delovi - AMS, extruder, kablovi. Originalni spare parts za sve serije.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
{ key: 'printer_model', label: 'Kompatibilnost', type: 'select' },
],
enabled: true,
},
{
slug: 'oprema',
label: 'Oprema i Dodaci (Accessories)',
labelShort: 'Oprema',
labelEn: 'Accessories',
apiCategory: 'accessory',
color: 'cyan',
colorHex: '#06b6d4',
icon: '🛠️',
description: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
metaTitle: 'Bambu Lab Oprema i Dodaci (Accessories) - Filamenteka',
metaDescription: 'Bambu Lab oprema - AMS, spool holder, alati. Dodatna oprema za 3D stampace.',
filters: [
{ key: 'condition', label: 'Stanje', type: 'select', options: ['new', 'used_like_new', 'used_good', 'used_fair'] },
],
enabled: true,
},
];
export const getCategoryBySlug = (slug: string): CategoryConfig | undefined =>
CATEGORIES.find(c => c.slug === slug);
export const getEnabledCategories = (): CategoryConfig[] =>
CATEGORIES.filter(c => c.enabled);
export const CONDITION_LABELS: Record<string, string> = {
new: 'Novo',
used_like_new: 'Kao novo',
used_good: 'Dobro stanje',
used_fair: 'Korisceno',
};

View File

@@ -1,59 +0,0 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface DarkModeContextType {
darkMode: boolean;
setDarkMode: (value: boolean) => void;
toggleDarkMode: () => void;
mounted: boolean;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export function DarkModeProvider({
children,
defaultDark = false,
}: {
children: ReactNode;
defaultDark?: boolean;
}) {
const [darkMode, setDarkMode] = useState(defaultDark);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
setDarkMode(JSON.parse(saved));
} else if (defaultDark) {
setDarkMode(true);
}
}, [defaultDark]);
useEffect(() => {
if (!mounted) return;
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode, mounted]);
const toggleDarkMode = () => setDarkMode(prev => !prev);
return (
<DarkModeContext.Provider value={{ darkMode, setDarkMode, toggleDarkMode, mounted }}>
{children}
</DarkModeContext.Provider>
);
}
export function useDarkMode(): DarkModeContextType {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
}

View File

@@ -33,8 +33,6 @@ 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' },
@@ -72,8 +70,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'Indigo Purple': { hex: '#482A60' },
'Iridium Gold Metallic': { hex: '#B39B84' },
'Iris Purple': { hex: '#69398E' },
'Iron Gray Metallic': { hex: '#6B6C6F' },
'IronGray Metallic': { hex: '#6B6C6F' },
'Ivory White': { hex: '#FFFFFF' },
'Jade White': { hex: '#FFFFFF' },
'Jeans Blue': { hex: '#6E88BC' },
@@ -103,7 +99,6 @@ 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' },
@@ -146,62 +141,6 @@ export const bambuLabColors: Record<string, ColorMapping> = {
'White Oak': { hex: '#D2CCA2' },
'Yellow': { hex: '#F4EE2A' },
// ABS Colors
// ABS GF Colors
'ABS GF Yellow': { hex: '#FDD835' },
'ABS GF Orange': { hex: '#F48438' },
// 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 Latte Brown': { hex: '#D3B7A7' },
'Matte Terracotta': { hex: '#A25A37' },
// PLA Silk Multi-Color
'Silk Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
// TPU 95A HF Colors
'TPU 95A HF Yellow': { hex: '#F3E600' },
// Default fallback
'Unknown': { hex: '#CCCCCC' }
};

View File

@@ -40,25 +40,6 @@ 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 Latte Brown": "#D3B7A7",
"Matte Terracotta": "#A25A37",
// Silk Colors
"Silk White": "#FEFEFE",
"Silk Black": "#0A0A0A",
@@ -79,11 +60,6 @@ export const bambuLabColors = {
"Silk Sapphire": "#1976D2",
"Silk Emerald": "#00695C",
// Silk Multi-Color
"Silk Aurora Purple": "#7F3696",
"Silk Phantom Blue": "#00629B",
"Silk Mystic Magenta": "#720062",
// Metal Colors
"Metal Grey": "#9E9E9E",
"Metal Silver": "#B0BEC5",
@@ -115,22 +91,7 @@ export const bambuLabColors = {
// Support Materials
"Natural": "#F5F5DC",
"Support White": "#F5F5F5",
"Support G": "#90CAF9",
// Metal Colors (PLA)
"Iron Gray Metallic": "#6B6C6F",
// ABS GF Colors
"ABS GF Yellow": "#FDD835",
"ABS GF Orange": "#F48438",
// TPU 95A HF Colors
"TPU 95A HF Yellow": "#F3E600",
// Wood Colors
"Ochre Yellow": "#BC8B39",
"White Oak": "#D2CCA2",
"Clay Brown": "#8E621A"
"Support G": "#90CAF9"
};
// Colors grouped by finish type for easier selection
@@ -144,11 +105,7 @@ export const colorsByFinish = {
"Matte": [
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
"Matte Latte Brown", "Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral"
],
"Silk": [
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
@@ -172,9 +129,6 @@ export const colorsByFinish = {
],
"Support": [
"Natural", "Support White", "Support G"
],
"Wood": [
"Ochre Yellow", "White Oak", "Clay Brown"
]
};

View File

@@ -1,36 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface UseAuthResult {
isAuthenticated: boolean;
isLoading: boolean;
logout: () => void;
}
export function useAuth(): UseAuthResult {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('authToken');
const expiry = localStorage.getItem('tokenExpiry');
if (token && expiry && Date.now() <= parseInt(expiry)) {
setIsAuthenticated(true);
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
window.location.href = '/upadaj';
}
setIsLoading(false);
}, []);
const logout = () => {
localStorage.removeItem('authToken');
localStorage.removeItem('tokenExpiry');
window.location.href = '/upadaj';
};
return { isAuthenticated, isLoading, logout };
}

View File

@@ -1,47 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Product, ProductCategory } from '@/src/types/product';
import { productService } from '@/src/services/api';
interface UseProductsOptions {
category?: ProductCategory;
condition?: string;
printer_model?: string;
in_stock?: boolean;
search?: string;
}
interface UseProductsResult {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useProducts(options: UseProductsOptions = {}): UseProductsResult {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchProducts = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await productService.getAll(options);
setProducts(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Greska pri ucitavanju proizvoda';
setError(message);
console.error('Error fetching products:', err);
} finally {
setLoading(false);
}
}, [options.category, options.condition, options.printer_model, options.in_stock, options.search]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
return { products, loading, error, refetch: fetchProducts };
}

View File

@@ -1,6 +1,5 @@
import axios from 'axios';
import { Filament } from '@/src/types/filament';
import { Product, ProductCategory, InventoryStats, SalesStats } from '@/src/types/product';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
@@ -112,8 +111,7 @@ export const colorRequestService = {
color_name: string;
material_type: string;
finish_type?: string;
user_email: string;
user_phone: string;
user_email?: string;
user_name?: string;
description?: string;
reference_url?: string;
@@ -133,73 +131,47 @@ export const colorRequestService = {
},
};
export const productService = {
getAll: async (params: {
category?: ProductCategory;
condition?: string;
printer_model?: string;
in_stock?: boolean;
search?: string;
} = {}) => {
const cacheBuster = Date.now();
const queryParams = new URLSearchParams({ _t: String(cacheBuster) });
if (params.category) queryParams.set('category', params.category);
if (params.condition) queryParams.set('condition', params.condition);
if (params.printer_model) queryParams.set('printer_model', params.printer_model);
if (params.in_stock !== undefined) queryParams.set('in_stock', String(params.in_stock));
if (params.search) queryParams.set('search', params.search);
const response = await api.get(`/products?${queryParams.toString()}`);
return response.data;
},
getById: async (id: string) => {
const response = await api.get(`/products/${id}`);
return response.data;
},
create: async (product: Partial<Product> & { printer_model_ids?: string[] }) => {
const response = await api.post('/products', product);
return response.data;
},
update: async (id: string, product: Partial<Product> & { printer_model_ids?: string[] }) => {
const response = await api.put(`/products/${id}`, product);
return response.data;
},
delete: async (id: string) => {
const response = await api.delete(`/products/${id}`);
return response.data;
},
updateBulkSale: async (data: {
productIds?: string[];
salePercentage: number;
saleStartDate?: string;
saleEndDate?: string;
enableSale: boolean;
}) => {
const response = await api.post('/products/sale/bulk', data);
return response.data;
},
};
export const printerModelService = {
export const printersService = {
getAll: async () => {
const response = await api.get('/printer-models');
try {
const response = await api.get('/printers');
return response.data;
} catch (error) {
return [];
}
},
};
export const analyticsService = {
getInventory: async (): Promise<InventoryStats> => {
const response = await api.get('/analytics/inventory');
export const printerRequestService = {
submit: async (request: any) => {
try {
const response = await api.post('/printer-requests', request);
return response.data;
} catch (error) {
return { success: true };
}
},
};
getSales: async (): Promise<SalesStats> => {
const response = await api.get('/analytics/sales');
export const gearService = {
getAll: async () => {
try {
const response = await api.get('/gear');
return response.data;
} catch (error) {
return [];
}
},
};
export const gearRequestService = {
submit: async (request: any) => {
try {
const response = await api.post('/gear-requests', request);
return response.data;
} catch (error) {
return { success: true };
}
},
};

View File

@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Nunito+Sans:ital,opsz,wght@0,6..12,300..1000;1,6..12,300..1000&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -12,244 +10,43 @@
z-index: 9999;
}
/* Prevent white flash on admin pages */
@layer base {
:root {
--font-display: 'Sora', system-ui, sans-serif;
--font-body: 'Nunito Sans', system-ui, sans-serif;
/* Category palette */
--color-filamenti: #3b82f6;
--color-stampaci: #ef4444;
--color-ploce: #22c55e;
--color-mlaznice: #f59e0b;
--color-delovi: #a855f7;
--color-oprema: #06b6d4;
/* Surface tokens — light (vibrant, clean) */
--surface-primary: #f8fafc;
--surface-secondary: #f1f5f9;
--surface-elevated: #ffffff;
--text-primary: #0f172a;
--text-secondary: #334155;
--text-muted: #64748b;
--border-color: #e2e8f0;
--border-subtle: #e2e8f0;
}
html.dark {
--surface-primary: #0f172a;
--surface-secondary: #1e293b;
--surface-elevated: #1e293b;
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border-color: rgba(255, 255, 255, 0.1);
--border-subtle: rgba(255, 255, 255, 0.06);
}
html {
background-color: var(--surface-primary);
overflow-x: hidden;
scroll-behavior: smooth;
background-color: rgb(249 250 251);
}
html.dark {
background-color: var(--surface-primary);
background-color: rgb(17 24 39);
}
body {
font-family: var(--font-body);
font-weight: 400;
background-color: var(--surface-primary);
color: var(--text-primary);
transition: none;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
letter-spacing: -0.03em;
font-weight: 700;
@apply bg-gray-50 dark:bg-gray-900 transition-none;
}
/* Disable transitions on page load to prevent flash */
.no-transitions * {
transition: none !important;
}
}
/* ─── Noise texture overlay ─── */
.noise-overlay::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 1;
}
.dark .noise-overlay::after {
opacity: 0.04;
}
/* ─── Gradient text utility ─── */
.gradient-text {
background: linear-gradient(135deg, #2563eb, #7c3aed, #c026d3);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
html.dark .gradient-text {
background: linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ─── Hero rainbow gradient ─── */
.hero-gradient-text {
background: linear-gradient(90deg, #3b82f6, #a855f7, #ef4444, #f59e0b, #22c55e, #06b6d4);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradient-shift 6s ease-in-out infinite;
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% center; }
50% { background-position: 100% center; }
}
/* ─── Glass card ─── */
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dark .glass-card {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: none;
}
/* ─── Category card hover glow ─── */
.category-card {
position: relative;
overflow: hidden;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.35s ease;
}
.category-card:hover {
transform: translateY(-6px) scale(1.02);
}
/* Light mode: softer shadow for elevation on light backgrounds */
:root .category-card {
box-shadow: 0 2px 12px -2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
}
:root .category-card:hover {
box-shadow: 0 12px 40px -8px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.06);
}
html.dark .category-card {
box-shadow: none;
}
html.dark .category-card:hover {
box-shadow: none;
}
/* ─── Shimmer animation ─── */
/* Shimmer animation for sale banner */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
.animate-shimmer {
animation: shimmer 3s ease-in-out infinite;
}
/* ─── Float animation ─── */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-16px); }
}
.animate-float {
animation: float 5s ease-in-out infinite;
}
.animate-float-slow {
animation: float 7s ease-in-out infinite;
}
.animate-float-delayed {
animation: float 6s ease-in-out 1.5s infinite;
}
/* ─── Pulse glow ─── */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(139, 92, 246, 0.3); }
50% { box-shadow: 0 0 40px rgba(139, 92, 246, 0.6); }
}
/* ─── Gradient orbit background ─── */
@keyframes orbit {
0% { transform: rotate(0deg) translateX(100px) rotate(0deg); }
100% { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
}
.animate-orbit {
animation: orbit 20s linear infinite;
}
.animate-orbit-reverse {
animation: orbit 25s linear infinite reverse;
}
/* ─── Staggered reveal ─── */
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal-up {
animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.reveal-delay-1 { animation-delay: 0.08s; }
.reveal-delay-2 { animation-delay: 0.16s; }
.reveal-delay-3 { animation-delay: 0.24s; }
.reveal-delay-4 { animation-delay: 0.32s; }
.reveal-delay-5 { animation-delay: 0.40s; }
.reveal-delay-6 { animation-delay: 0.48s; }
/* ─── Scrollbar hide ─── */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* ─── Safari form styling fixes ─── */
/* Safari form styling fixes */
@layer base {
/* Remove Safari's default styling for form inputs */
input[type="text"],
input[type="email"],
input[type="url"],
@@ -264,6 +61,7 @@ html.dark .category-card:hover {
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;
@@ -272,14 +70,19 @@ html.dark .category-card:hover {
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");
}
input, select, textarea {
border-radius: 0.5rem;
/* 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"],

View File

@@ -1,73 +0,0 @@
export type ProductCategory = 'printer' | 'build_plate' | 'nozzle' | 'spare_part' | 'accessory';
export type ProductCondition = 'new' | 'used_like_new' | 'used_good' | 'used_fair';
export interface Product {
id: string;
category: ProductCategory;
name: string;
slug: string;
description?: string;
price: number;
condition: ProductCondition;
stock: number;
attributes: Record<string, unknown>;
sale_active?: boolean;
sale_percentage?: number;
sale_start_date?: string;
sale_end_date?: string;
image_url?: string;
is_active?: boolean;
compatible_printers?: string[];
created_at?: string;
updated_at?: string;
}
export interface PrinterModel {
id: string;
name: string;
series: string;
}
export interface InventoryStats {
filaments: {
total_skus: number;
total_units: number;
total_refills: number;
total_spools: number;
out_of_stock: number;
inventory_value: number;
};
products: {
total_skus: number;
total_units: number;
out_of_stock: number;
inventory_value: number;
by_category: Record<string, number>;
};
combined: {
total_skus: number;
total_units: number;
out_of_stock: number;
};
}
export interface SalesStats {
filament_sales: Array<{
id: string;
name: string;
sale_percentage: number;
sale_end_date?: string;
original_price: number;
sale_price: number;
}>;
product_sales: Array<{
id: string;
name: string;
category: ProductCategory;
sale_percentage: number;
sale_end_date?: string;
original_price: number;
sale_price: number;
}>;
total_active_sales: number;
}

View File

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

@@ -1,18 +0,0 @@
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,5 +20,117 @@ provider "cloudflare" {
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
}
# Frontend hosted on CloudFront + S3 (see cloudfront-frontend.tf)
# CI/CD handled by Gitea Actions (see .gitea/workflows/deploy.yml)
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"
}
}

View File

@@ -1,23 +1,22 @@
# ===== 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 "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
@@ -60,24 +59,3 @@ 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"
}

View File

@@ -1,5 +1,9 @@
# Copy this file to terraform.tfvars and fill in your values
# GitHub repository for Amplify
github_repository = "https://github.com/yourusername/filamenteka"
github_token = "ghp_your_github_token_here"
# Domain configuration
domain_name = "filamenteka.yourdomain.com"

View File

@@ -1,3 +1,14 @@
variable "github_repository" {
description = "GitHub repository URL"
type = string
}
variable "github_token" {
description = "GitHub personal access token for Amplify"
type = string
sensitive = true
}
variable "domain_name" {
description = "Custom domain name (optional)"
type = string

Some files were not shown because too many files have changed in this diff Show More