35 Commits

Author SHA1 Message Date
DaX
1d3d11afec Refactor to multi-category catalog with polished light mode
- Restructure from single filament table to multi-category product catalog
  (filamenti, stampaci, ploce, mlaznice, delovi, oprema)
- Add shared layout components (SiteHeader, SiteFooter, CategoryNav, Breadcrumb)
- Add reusable UI primitives (Badge, Button, Card, Modal, PriceDisplay, EmptyState)
- Add catalog components (CatalogPage, ProductTable, ProductGrid, FilamentCard, ProductCard)
- Add admin dashboard with sidebar navigation and category management
- Add product API endpoints and database migrations
- Add SEO pages (politika-privatnosti, uslovi-koriscenja, robots.txt, sitemap.xml)
- Fix light mode: gradient text contrast, category nav accessibility,
  surface tokens, card shadows, CTA section theming
2026-02-21 21:56:17 +01:00
DaX
a854fd5524 Upgrade to Next.js 16.1.6, React 19.2.4, and update all dependencies
All checks were successful
Deploy / deploy (push) Successful in 59s
- Next.js 15.5 → 16.1.6 (Turbopack default, faster builds)
- React/React-DOM 19.1 → 19.2.4
- @types/react 18 → 19, @types/react-dom 18 → 19
- TypeScript 5.2 → 5.9
- CI Node.js 18 → 20 (required by Next.js 16)
- Replace removed next lint with standalone ESLint config
- Auto-updated tsconfig.json (jsx: react-jsx, dev types)
2026-02-16 02:39:22 +01:00
DaX
eff60436eb Clean up repo: remove amplify.yml, update gitignore
All checks were successful
Deploy / deploy (push) Successful in 1m29s
Delete deprecated Amplify config leftover from Gitea migration.
Update next-env.d.ts for Next.js 15.5 route types.
Add .mcp.json and *.png to gitignore.
2026-02-16 02:33:35 +01:00
DaX
6dda083ee9 Streamline CLAUDE.md: remove redundancy, fix inaccuracies
All checks were successful
Deploy / deploy (push) Successful in 1m22s
Remove exhaustive component/test/terraform listings that are easily
discoverable. Fix non-existent typecheck script reference. Consolidate
deployment info into single table. Add missing Matomo env vars and
single-test-file command.
2026-02-16 02:16:37 +01:00
DaX
bd27f7a9e4 Fix pip3 install on Ubuntu runner with --break-system-packages flag
All checks were successful
Deploy / deploy (push) Successful in 1m18s
2026-02-16 02:08:08 +01:00
DaX
145c2d4781 Optimize CI/CD pipeline for faster deploys
Some checks failed
Deploy / deploy (push) Failing after 1m12s
- Remove broken npm cache (was timing out ~5min per run)
- Replace AWS CLI v2 installer with pip3 install (~50MB less)
- Inline AWS credential config instead of separate action
- Merge steps to reduce overhead
2026-02-16 02:04:38 +01:00
DaX
28ba314404 Add missing filament colors and fix naming issues
All checks were successful
Deploy / deploy (push) Successful in 10m43s
- Add Silk Multi-Color gradients: Aurora Purple, Phantom Blue, Mystic Magenta
- Add Iron Gray Metallic alias (with space)
- Add Matte Latte Brown
2026-02-16 01:51:42 +01:00
DaX
0cfee1bda7 Update all dependencies to latest stable versions
All checks were successful
Deploy / deploy (push) Successful in 10m52s
Frontend: react 19.2.4, next 15.5.12, axios 1.13.5, jest 30.2.0,
typescript 5.9.3, tailwindcss 3.4.19, autoprefixer 10.4.24
API: cors 2.8.6, dotenv 16.6.1, express 4.22.1, pg 8.18.0,
jsonwebtoken 9.0.3, nodemon 3.1.11
2026-02-16 01:02:59 +01:00
DaX
7cd2058613 Add AWS CLI install step to deploy workflow
All checks were successful
Deploy / deploy (push) Successful in 10m52s
Runner image doesn't include AWS CLI by default.
2026-02-16 00:49:52 +01:00
DaX
c837af6015 Fix workflow: use env block instead of vars context
Some checks failed
Deploy / deploy (push) Failing after 5m59s
Gitea act runner doesn't resolve vars context properly.
Move all non-sensitive config to workflow-level env block.
2026-02-16 00:43:13 +01:00
DaX
58c165749d Migrate from GitHub to Gitea with CI/CD
Some checks failed
Deploy / deploy (push) Failing after 6m11s
- Add Gitea Actions workflow for automated frontend and API deployment
- Update all raw download URLs from GitHub to Gitea
- Remove deprecated Amplify config and GitHub-specific Terraform variables
- Clean up commented-out Amplify resources from Terraform
- Update documentation to reflect new repository and CI/CD setup
2026-02-16 00:35:08 +01:00
DaX
b7f5417e23 Move dashboard to root level route
- Dashboard now accessible at /dashboard instead of /upadaj/dashboard
- Updated all navigation links to use new /dashboard route
- Login page still at /upadaj, redirects to /dashboard on success
- Colors and requests remain under /upadaj prefix
- Updated test files to reference new dashboard path
2025-11-19 19:13:48 +01:00
DaX
17edfc8794 Fix CloudFront routing and TypeScript type safety
- Update CloudFront Function to handle Next.js static export .html files
- Fix TypeScript interface for color request service (add required user_phone field)
- Update ColorRequestForm component to include phone field
2025-11-19 18:56:08 +01:00
DaX
f6f9da9c5b Add bulk price editing features and fix quantity update price preservation
- Fix FilamentForm to preserve prices when updating quantities
  - Add isInitialLoad flag to prevent price override on form load
  - Only update prices when color is actively changed, not on initial render

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

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

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

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

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

15
.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"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/"]
}

110
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,110 @@
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,3 +58,9 @@ temp-*.sh
# Lambda packages # Lambda packages
lambda/*.zip lambda/*.zip
lambda/**/node_modules/ lambda/**/node_modules/
# MCP config (local dev tooling)
.mcp.json
# Images/screenshots
*.png

206
CLAUDE.md
View File

@@ -1,168 +1,132 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance for AI-assisted development in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project Overview
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of: Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export) - **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export to `/out`)
- **Backend**: Node.js API server with PostgreSQL database - **Backend**: Node.js Express API server (`/api/server.js`) with PostgreSQL database
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database) - **Infrastructure**: AWS (CloudFront + S3 for frontend, EC2 for API, RDS for database)
- **Repository**: `git.demirix.dev/dax/Filamenteka`
## Critical Rules ## Critical Rules
- NEVER mention ANY author in commits. No author tags or attribution - NEVER mention ANY author in commits. No author tags or attribution of any kind
- 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) - Build for AMD64 Linux when deploying (development is on ARM macOS)
- Always run security checks before commits - Always run security checks before commits (Husky pre-commit hook does this automatically)
## Common Commands ## Common Commands
```bash ```bash
# Development # Development
npm run dev # Start Next.js development server (port 3000) npm run dev # Start Next.js dev server (port 3000)
npm run build # Build static export to /out directory npm run build # Build static export to /out
npm run lint # Run ESLint npm run lint # ESLint
npm test # Run Jest tests npm test # Jest tests
npm run test:watch # Jest watch mode
npm test -- --testPathPattern=__tests__/components/ColorCell # Run single test file
# Security & Quality # API Development
npm run security:check # Check for credential leaks cd api && npm run dev # Start API with nodemon (port 4000)
npm run test:build # Test if build succeeds
# Database Migrations # Quality Gates
npm run migrate # Run pending migrations npm run security:check # Credential leak detection
npm run migrate:clear # Clear migration history npm run test:build # Verify build succeeds without deploying
npx tsc --noEmit # TypeScript type checking
# Pre-commit Hook # Database
./scripts/pre-commit.sh # Runs security, build, and test checks npm run migrate # Run pending migrations locally
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
# 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
``` ```
## Architecture ## Architecture
### Frontend Structure (Next.js App Router) ### Frontend (Next.js App Router)
- `/app` - Next.js 13+ app directory structure - `/app/page.tsx` — Public filament inventory table (home page)
- `/page.tsx` - Public filament inventory table - `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/requests` (customer color requests)
- `/upadaj` - Admin panel (password protected) - `/src/services/api.ts` Axios instance with auth interceptors (auto token injection, 401/403 redirect)
- `/page.tsx` - Admin login - `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component)
- `/dashboard/page.tsx` - Filament CRUD operations - `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
- `/colors/page.tsx` - Color management
### API Structure ### API (`/api/server.js`)
- `/api` - Node.js Express server (runs on EC2) Single-file Express server running on EC2 (port 80). All routes inline.
- `/server.js` - Main server file - Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
- `/routes` - API endpoints for filaments, colors, auth - Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
- Database: PostgreSQL on AWS RDS
### Key Components ### Database (PostgreSQL on RDS)
- `FilamentTableV2` - Main inventory display with sorting/filtering Key schemas and constraints:
- `SaleManager` - Bulk sale management interface
- `ColorCell` - Smart color rendering with gradient support
- `EnhancedFilters` - Advanced filtering system
### Data Models ```
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
#### Filament Schema (PostgreSQL) colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
```sql color_requests: id, color_name, message, contact_name, contact_phone, status
filaments: {
id: UUID,
tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk)
boja: VARCHAR(100), # Color name
refill: INTEGER, # Refill spool count
spulna: INTEGER, # Regular spool count
kolicina: INTEGER, # Total quantity (refill + spulna)
cena: VARCHAR(50), # Price
sale_active: BOOLEAN, # Sale status
sale_percentage: INTEGER,# Sale discount
sale_end_date: TIMESTAMP # Sale expiry
}
``` ```
#### Color Schema **Critical constraints:**
```sql - `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
colors: { - `kolicina = refill + spulna` — enforced by check constraint
id: UUID, - Migrations in `/database/migrations/` with sequential numbering (currently up to `020_`)
name: VARCHAR(100), # Color name (must match filament.boja)
hex: VARCHAR(7), # Hex color code
cena_refill: INTEGER, # Refill price (default: 3499)
cena_spulna: INTEGER # Regular price (default: 3999)
}
```
## Deployment ## Key Patterns
### Frontend (AWS Amplify) ### Color Management (two-layer system)
- Automatic deployment on push to main branch 1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
- Build output: Static files in `/out` directory 2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
- Config: `amplify.yml`, `next.config.js` (output: 'export') 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.
### API Server (EC2) ### Adding New Colors
- Manual deployment via `scripts/deploy-api.sh` 1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
- Server: `3.71.161.51` 2. Add hex mapping to `src/data/bambuLabColors.ts`
- Domain: `api.filamenteka.rs` 3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
### Database (RDS PostgreSQL)
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
- Migrations in `/database/migrations/`
- Schema in `/database/schema.sql`
## Important Patterns
### API Communication ### API Communication
- All API calls use axios interceptors for auth (`src/services/api.ts`) - Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`
- Auth token stored in localStorage - Auth token stored in localStorage with 24h expiry
- Automatic redirect on 401/403 in admin routes - Cache busting on filament fetches via timestamp query param
### Color Management ## CI/CD (Gitea Actions)
- Colors defined in `src/data/bambuLabColors.ts` and `bambuLabColorsComplete.ts`
- Automatic row coloring based on filament color
- Special handling for gradient filaments
### State Management `.gitea/workflows/deploy.yml` triggers on push to `main`. Auto-detects changes via `git diff HEAD~1`:
- React hooks for local state - **Frontend changes** (anything outside `api/`): security check → tests → build → S3 deploy (3-tier cache: HTML no-cache, `_next/` immutable, rest 24h) → CloudFront invalidation
- No global state management library - **API changes** (`api/` files): SSM command to EC2 to pull `server.js` from Gitea and restart `node-api` service
- Data fetching in components with useEffect - Both run if both changed in a single push
### Testing ## Deployment Details
- Jest + React Testing Library
- Tests in `__tests__/` directory | Component | Target | Method |
- Coverage goal: >80% |-----------|--------|--------|
| 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 |
## Environment Variables ## Environment Variables
```bash ```bash
# Frontend (.env.local) # Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api 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 # API Server (.env in /api)
DATABASE_URL=postgresql://... DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
JWT_SECRET=... JWT_SECRET=...
ADMIN_PASSWORD=...
NODE_ENV=production NODE_ENV=production
PORT=80
``` ```
## Security Considerations ## Pre-commit Hooks (Husky)
- Admin routes protected by JWT authentication `scripts/pre-commit.sh` runs automatically and blocks commits that fail:
- Password hashing with bcrypt 1. Author mention check (blocks attribution)
- SQL injection prevention via parameterized queries 2. Security check (credential leaks)
- Credential leak detection in pre-commit hooks 3. Build test (ensures compilation)
- CORS configured for production domains only 4. Unit tests (`jest --passWithNoTests`)
## Database Operations
When modifying the database:
1. Create migration file in `/database/migrations/`
2. Test locally first
3. Run migration on production via scripts
4. Update corresponding TypeScript types
## Terraform Infrastructure
Infrastructure as Code in `/terraform/`:
- VPC and networking setup
- EC2 instance for API
- RDS PostgreSQL database
- Application Load Balancer
- ECR for Docker images
- Cloudflare DNS integration

View File

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

View File

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

View File

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

View File

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

1104
app/dashboard/page.tsx Normal file

File diff suppressed because it is too large Load Diff

18
app/delovi/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/delovi/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'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>
);
}

18
app/filamenti/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/filamenti/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'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,8 +4,27 @@ import { BackToTop } from '../src/components/BackToTop'
import { MatomoAnalytics } from '../src/components/MatomoAnalytics' import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Filamenteka', metadataBase: new URL('https://filamenteka.rs'),
description: 'Automatsko praćenje filamenata sa kodiranjem bojama', 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',
},
} }
export default function RootLayout({ export default function RootLayout({
@@ -13,27 +32,40 @@ export default function RootLayout({
}: { }: {
children: React.ReactNode 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 ( return (
<html lang="sr" suppressHydrationWarning> <html lang="sr" suppressHydrationWarning>
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
(function() { (function() {
document.documentElement.classList.add('no-transitions'); document.documentElement.classList.add('no-transitions');
// Apply dark mode immediately for admin pages if (window.location.pathname.startsWith('/upadaj') || window.location.pathname.startsWith('/dashboard')) {
if (window.location.pathname.startsWith('/upadaj')) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else { } else {
// For non-admin pages, check localStorage
try { try {
const darkMode = localStorage.getItem('darkMode'); var darkMode = localStorage.getItem('darkMode');
if (darkMode === 'true') { if (darkMode === 'true') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
} catch (e) {} } catch (e) {}
} }
// Remove no-transitions class after a short delay
window.addEventListener('load', function() { window.addEventListener('load', function() {
setTimeout(function() { setTimeout(function() {
document.documentElement.classList.remove('no-transitions'); document.documentElement.classList.remove('no-transitions');
@@ -43,6 +75,10 @@ export default function RootLayout({
`, `,
}} }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head> </head>
<body suppressHydrationWarning> <body suppressHydrationWarning>
{children} {children}

18
app/mlaznice/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/mlaznice/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'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>
);
}

18
app/oprema/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/oprema/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
'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,221 +1,384 @@
'use client' 'use client'
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FilamentTableV2 } from '../src/components/FilamentTableV2'; import Link from 'next/link';
import { SaleCountdown } from '../src/components/SaleCountdown'; import { SiteHeader } from '@/src/components/layout/SiteHeader';
import { Filament } from '../src/types/filament'; import { SiteFooter } from '@/src/components/layout/SiteFooter';
import { filamentService } from '../src/services/api'; import { SaleCountdown } from '@/src/components/SaleCountdown';
import { trackEvent } from '../src/components/MatomoAnalytics'; 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';
export default function Home() { export default function Home() {
const [filaments, setFilaments] = useState<Filament[]>([]); const [filaments, setFilaments] = useState<Filament[]>([]);
const [loading, setLoading] = useState(true); const [showColorRequestModal, setShowColorRequestModal] = useState(false);
const [error, setError] = useState<string | null>(null);
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [resetKey, setResetKey] = useState(0);
// Removed V1/V2 toggle - now only using V2
// Initialize dark mode from localStorage after mounting const categories = getEnabledCategories();
useEffect(() => { setMounted(true); }, []);
useEffect(() => { useEffect(() => {
setMounted(true); const fetchFilaments = async () => {
const saved = localStorage.getItem('darkMode'); try {
if (saved) { const data = await filamentService.getAll();
setDarkMode(JSON.parse(saved)); setFilaments(data);
} } catch (err) {
}, []); console.error('Failed to fetch filaments for sale check:', err);
// 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(); 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 = () => { const activeSaleFilaments = filaments.filter(f => f.sale_active === true);
setResetKey(prev => prev + 1); const hasActiveSale = activeSaleFilaments.length > 0;
window.scrollTo({ top: 0, behavior: 'smooth' }); 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;
})();
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors"> <div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
<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"> <SiteHeader />
<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>
<div className="flex-shrink-0"> {/* ═══ HERO ═══ */}
{mounted ? ( <section className="relative overflow-hidden noise-overlay">
<button {/* Rich multi-layer gradient background */}
onClick={() => { <div className="absolute inset-0 bg-gradient-to-br from-blue-950 via-purple-950 to-indigo-950" />
setDarkMode(!darkMode);
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark'); {/* Floating color orbs — big, bright, energetic */}
}} <div className="absolute inset-0 overflow-hidden" aria-hidden="true">
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" <div className="absolute w-[600px] h-[600px] -top-48 -left-48 rounded-full opacity-40 blur-[100px] animate-float"
title={darkMode ? 'Svetla tema' : 'Tamna tema'} 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"
{darkMode ? '☀️' : '🌙'} style={{ background: 'radial-gradient(circle, #a855f7, transparent)' }} />
</button> <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="w-10 h-10" /> <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> <div className="absolute w-[350px] h-[350px] top-1/2 left-10 rounded-full opacity-35 blur-[70px] animate-float-slow"
</div> 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> </div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Grid pattern overlay */}
{/* Logo centered above content */} <div className="absolute inset-0 opacity-[0.03]"
<div className="flex justify-center mb-8"> style={{
<button backgroundImage: 'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
onClick={handleLogoClick} backgroundSize: '60px 60px'
className="group transition-transform duration-200" }} />
title="Klikni za reset"
> <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">
{/* Using next/image would cause preload, so we use regular img with loading="lazy" */} <div className="flex flex-col items-center text-center reveal-up">
{/* Logo */}
<img <img
src="/logo.png" src="/logo.png"
alt="Filamenteka" alt="Filamenteka"
loading="lazy" loading="eager"
decoding="async" decoding="async"
className="h-36 sm:h-44 md:h-52 w-auto drop-shadow-lg group-hover:drop-shadow-2xl transition-all duration-200" className="h-24 sm:h-32 md:h-40 w-auto drop-shadow-2xl mb-8"
onError={(e) => { onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/> />
</button>
</div>
{/* Action Buttons */} {/* Tagline */}
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-8"> <h1
<a className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-white mb-5 tracking-tight leading-[1.1]"
href="https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246" style={{ fontFamily: 'var(--font-display)' }}
target="_blank" >
rel="noopener noreferrer" Bambu Lab oprema
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Homepage')} <br />
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" <span className="hero-gradient-text">
> u Srbiji
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </span>
<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" /> </h1>
</svg> <p className="text-lg sm:text-xl text-gray-300 max-w-xl mb-10 leading-relaxed reveal-up reveal-delay-1">
Kupi na Kupujem Prodajem Filamenti, stampaci, podloge, mlaznice, delovi i oprema.
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <br className="hidden sm:block" />
<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" /> Originalni proizvodi, privatna prodaja.
</svg> </p>
</a>
<a {/* CTA */}
href="tel:+381677102845" <div className="flex flex-col sm:flex-row gap-3 sm:gap-4 reveal-up reveal-delay-2">
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>
<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}
/>
</main>
<footer className="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col 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 <a
href="tel:+381677102845" href={KP_URL}
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" target="_blank"
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')} 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-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /> <path strokeLinecap="round" strokeLinejoin="round" 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> </svg>
+381 67 710 2845 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> </a>
</div> </div>
</div> </div>
</div> </div>
</footer>
{/* 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`,
}}
>
<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>
</div>
</div>
</section>
</main>
<SiteFooter />
<ColorRequestModal
isOpen={showColorRequestModal}
onClose={() => setShowColorRequestModal(false)}
/>
</div> </div>
); );
} }

18
app/ploce/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/ploce/page.tsx Normal file
View File

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

@@ -0,0 +1,205 @@
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>
);
}

18
app/stampaci/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
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}</>;
}

43
app/stampaci/page.tsx Normal file
View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

4279
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

8
public/robots.txt Normal file
View File

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

48
public/sitemap.xml Normal file
View File

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,190 @@
'use client';
import React, { useState } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestFormProps {
onSuccess?: () => void;
}
export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
const response = await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_name: '',
user_email: '',
user_phone: '',
description: '',
reference_url: ''
});
if (onSuccess) onSuccess();
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-800 dark:text-gray-100">Zatraži Novu Boju</h2>
{message && (
<div className={`mb-4 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Materijala *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
</select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tip Finiša
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
</select>
</div>
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
id="user_email"
name="user_email"
required
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon *
</label>
<input
type="tel"
id="user_phone"
name="user_phone"
required
value={formData.user_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
placeholder="063 123 4567"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji Zahtev'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,232 @@
'use client';
import React, { useState, useEffect } from 'react';
import { colorRequestService } from '@/src/services/api';
interface ColorRequestModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function ColorRequestModal({ isOpen, onClose }: ColorRequestModalProps) {
const [formData, setFormData] = useState({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset form when modal closes
setFormData({
color_name: '',
material_type: 'PLA',
finish_type: 'Basic',
user_email: '',
user_phone: ''
});
setMessage(null);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setMessage(null);
try {
await colorRequestService.submit(formData);
setMessage({
type: 'success',
text: 'Vaš zahtev je uspešno poslat!'
});
// Close modal after 2 seconds on success
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
});
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Zatraži Novu Boju
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ne možete pronaći boju koju tražite? Javite nam!
</p>
{message && (
<div className={`mb-4 p-3 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="color_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Naziv Boje *
</label>
<input
type="text"
id="color_name"
name="color_name"
required
value={formData.color_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="npr. Sunset Orange"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="material_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Materijal *
</label>
<select
id="material_type"
name="material_type"
required
value={formData.material_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="PLA-CF">PLA-CF</option>
<option value="PETG-CF">PETG-CF</option>
<option value="Other">Ostalo</option>
</select>
</div>
<div>
<label htmlFor="finish_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Finiš
</label>
<select
id="finish_type"
name="finish_type"
value={formData.finish_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
>
<option value="Basic">Basic</option>
<option value="Matte">Matte</option>
<option value="Silk">Silk</option>
<option value="Metal">Metal</option>
<option value="Sparkle">Sparkle</option>
<option value="Glow">Glow</option>
<option value="Transparent">Transparent</option>
<option value="Other">Ostalo</option>
</select>
</div>
</div>
<div>
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email *
</label>
<input
type="email"
id="user_email"
name="user_email"
required
value={formData.user_email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="Za obaveštenja o statusu"
/>
</div>
<div>
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Telefon *
</label>
<input
type="tel"
id="user_phone"
name="user_phone"
required
value={formData.user_phone}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 appearance-none"
placeholder="Za kontakt"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Otkaži
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-6 py-2 rounded-md text-white font-medium ${
isSubmitting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-purple-600 hover:bg-purple-700'
} transition-colors`}
>
{isSubmitting ? 'Slanje...' : 'Pošalji'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,50 @@
'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>
);
}

104
src/components/ui/Modal.tsx Normal file
View File

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

@@ -0,0 +1,52 @@
'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>
);
}

148
src/config/categories.ts Normal file
View File

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

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

View File

@@ -40,6 +40,25 @@ export const bambuLabColors = {
"Matte Navy": "#1A237E", "Matte Navy": "#1A237E",
"Matte Coral": "#FF5252", "Matte Coral": "#FF5252",
// Matte Colors - New 2025
"Matte Apple Green": "#C6E188",
"Matte Bone White": "#C8C5B6",
"Matte Caramel": "#A4845C",
"Matte Dark Blue": "#042F56",
"Matte Dark Brown": "#7D6556",
"Matte Dark Chocolate": "#4A3729",
"Matte Dark Green": "#68724D",
"Matte Dark Red": "#BB3D43",
"Matte Grass Green": "#61C680",
"Matte Ice Blue": "#A3D8E1",
"Matte Lemon Yellow": "#F7D959",
"Matte Lilac Purple": "#AE96D4",
"Matte Plum": "#851A52",
"Matte Sakura Pink": "#E8AFCF",
"Matte Sky Blue": "#73B2E5",
"Matte Latte Brown": "#D3B7A7",
"Matte Terracotta": "#A25A37",
// Silk Colors // Silk Colors
"Silk White": "#FEFEFE", "Silk White": "#FEFEFE",
"Silk Black": "#0A0A0A", "Silk Black": "#0A0A0A",
@@ -60,6 +79,11 @@ export const bambuLabColors = {
"Silk Sapphire": "#1976D2", "Silk Sapphire": "#1976D2",
"Silk Emerald": "#00695C", "Silk Emerald": "#00695C",
// Silk Multi-Color
"Silk Aurora Purple": "#7F3696",
"Silk Phantom Blue": "#00629B",
"Silk Mystic Magenta": "#720062",
// Metal Colors // Metal Colors
"Metal Grey": "#9E9E9E", "Metal Grey": "#9E9E9E",
"Metal Silver": "#B0BEC5", "Metal Silver": "#B0BEC5",
@@ -91,7 +115,22 @@ export const bambuLabColors = {
// Support Materials // Support Materials
"Natural": "#F5F5DC", "Natural": "#F5F5DC",
"Support White": "#F5F5F5", "Support White": "#F5F5F5",
"Support G": "#90CAF9" "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"
}; };
// Colors grouped by finish type for easier selection // Colors grouped by finish type for easier selection
@@ -105,7 +144,11 @@ export const colorsByFinish = {
"Matte": [ "Matte": [
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green", "Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey", "Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral" "Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
"Matte Latte Brown", "Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
], ],
"Silk": [ "Silk": [
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green", "Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
@@ -129,6 +172,9 @@ export const colorsByFinish = {
], ],
"Support": [ "Support": [
"Natural", "Support White", "Support G" "Natural", "Support White", "Support G"
],
"Wood": [
"Ochre Yellow", "White Oak", "Clay Brown"
] ]
}; };

36
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,36 @@
'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 };
}

47
src/hooks/useProducts.ts Normal file
View File

@@ -0,0 +1,47 @@
'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,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { Filament } from '@/src/types/filament'; 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'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
@@ -101,4 +102,105 @@ export const filamentService = {
}, },
}; };
export const colorRequestService = {
getAll: async () => {
const response = await api.get('/color-requests');
return response.data;
},
submit: async (request: {
color_name: string;
material_type: string;
finish_type?: string;
user_email: string;
user_phone: string;
user_name?: string;
description?: string;
reference_url?: string;
}) => {
const response = await api.post('/color-requests', request);
return response.data;
},
updateStatus: async (id: string, status: string, admin_notes?: string) => {
const response = await api.put(`/color-requests/${id}`, { status, admin_notes });
return response.data;
},
delete: async (id: string) => {
const response = await api.delete(`/color-requests/${id}`);
return response.data;
},
};
export 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 = {
getAll: async () => {
const response = await api.get('/printer-models');
return response.data;
},
};
export const analyticsService = {
getInventory: async (): Promise<InventoryStats> => {
const response = await api.get('/analytics/inventory');
return response.data;
},
getSales: async (): Promise<SalesStats> => {
const response = await api.get('/analytics/sales');
return response.data;
},
};
export default api; export default api;

View File

@@ -1,3 +1,5 @@
@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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -10,36 +12,285 @@
z-index: 9999; z-index: 9999;
} }
/* Prevent white flash on admin pages */
@layer base { @layer base {
html { :root {
background-color: rgb(249 250 251); --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 { html.dark {
background-color: rgb(17 24 39); --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;
}
html.dark {
background-color: var(--surface-primary);
} }
body { body {
@apply bg-gray-50 dark:bg-gray-900 transition-none; 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;
} }
/* Disable transitions on page load to prevent flash */
.no-transitions * { .no-transitions * {
transition: none !important; transition: none !important;
} }
} }
/* Shimmer animation for sale banner */ /* ─── 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 ─── */
@keyframes shimmer { @keyframes shimmer {
0% { 0% { transform: translateX(-100%); }
transform: translateX(-100%); 100% { transform: translateX(200%); }
}
100% {
transform: translateX(200%);
}
} }
.animate-shimmer { .animate-shimmer {
animation: shimmer 3s ease-in-out infinite; 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 ─── */
@layer base {
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.7rem center;
background-size: 1em;
padding-right: 2.5rem;
}
.dark 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;
}
@media screen and (max-width: 768px) {
input[type="text"],
input[type="email"],
input[type="url"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
font-size: 16px;
}
}
}

73
src/types/product.ts Normal file
View File

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

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

View File

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

View File

@@ -20,117 +20,5 @@ provider "cloudflare" {
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
} }
resource "aws_amplify_app" "filamenteka" { # Frontend hosted on CloudFront + S3 (see cloudfront-frontend.tf)
name = "filamenteka" # CI/CD handled by Gitea Actions (see .gitea/workflows/deploy.yml)
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,22 +1,23 @@
output "app_id" { # ===== DEPRECATED: Amplify Outputs (removed after migration to CloudFront) =====
description = "The ID of the Amplify app" # output "app_id" {
value = aws_amplify_app.filamenteka.id # description = "The ID of the Amplify app"
} # value = aws_amplify_app.filamenteka.id
# }
output "app_url" { #
description = "The default URL of the Amplify app" # output "app_url" {
value = "https://main.${aws_amplify_app.filamenteka.default_domain}" # description = "The default URL of the Amplify app"
} # value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
# }
output "dev_url" { #
description = "The development branch URL" # output "dev_url" {
value = "https://dev.${aws_amplify_app.filamenteka.default_domain}" # description = "The development branch URL"
} # value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
# }
output "custom_domain_url" { #
description = "The custom domain URL (if configured)" # output "custom_domain_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured" # description = "The custom domain URL (if configured)"
} # value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
# }
output "rds_endpoint" { output "rds_endpoint" {
value = aws_db_instance.filamenteka.endpoint value = aws_db_instance.filamenteka.endpoint
@@ -59,3 +60,24 @@ output "aws_region" {
description = "AWS Region" description = "AWS Region"
} }
# CloudFront + S3 Frontend Outputs
output "s3_bucket_name" {
value = aws_s3_bucket.frontend.id
description = "S3 bucket name for frontend"
}
output "cloudfront_distribution_id" {
value = aws_cloudfront_distribution.frontend.id
description = "CloudFront distribution ID"
}
output "cloudfront_domain_name" {
value = aws_cloudfront_distribution.frontend.domain_name
description = "CloudFront distribution domain name"
}
output "frontend_url" {
value = var.domain_name != "" ? "https://${var.domain_name}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
description = "Frontend URL"
}

View File

@@ -1,9 +1,5 @@
# Copy this file to terraform.tfvars and fill in your values # 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 configuration
domain_name = "filamenteka.yourdomain.com" domain_name = "filamenteka.yourdomain.com"

View File

@@ -1,14 +1,3 @@
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" { variable "domain_name" {
description = "Custom domain name (optional)" description = "Custom domain name (optional)"
type = string type = string

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -12,7 +16,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -20,9 +24,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

File diff suppressed because one or more lines are too long