Compare commits
29 Commits
f1f3a65dfd
...
refactor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3d11afec | ||
|
|
a854fd5524 | ||
|
|
eff60436eb | ||
|
|
6dda083ee9 | ||
|
|
bd27f7a9e4 | ||
|
|
145c2d4781 | ||
|
|
28ba314404 | ||
|
|
0cfee1bda7 | ||
|
|
7cd2058613 | ||
|
|
c837af6015 | ||
|
|
58c165749d | ||
|
|
b7f5417e23 | ||
|
|
17edfc8794 | ||
|
|
f6f9da9c5b | ||
|
|
b1dfa2352a | ||
|
|
987039b0f7 | ||
|
|
6bc1c8d16d | ||
|
|
d3e001707b | ||
|
|
c0ca1e4bb3 | ||
|
|
1ce127c51c | ||
|
|
fc95dc4ed2 | ||
|
|
543e51cc3c | ||
|
|
56a21b27fe | ||
|
|
f99a132e7b | ||
|
|
990221792a | ||
|
|
6f75abf6ee | ||
|
|
01a24e781b | ||
|
|
92b186b6bc | ||
|
|
06b0a20bef |
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal 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
110
.gitea/workflows/deploy.yml
Normal 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
6
.gitignore
vendored
@@ -58,3 +58,9 @@ temp-*.sh
|
||||
# Lambda packages
|
||||
lambda/*.zip
|
||||
lambda/**/node_modules/
|
||||
|
||||
# MCP config (local dev tooling)
|
||||
.mcp.json
|
||||
|
||||
# Images/screenshots
|
||||
*.png
|
||||
275
CLAUDE.md
275
CLAUDE.md
@@ -1,231 +1,132 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments with:
|
||||
- **Frontend**: Next.js 15 app with React 19, TypeScript 5.2.2, and Tailwind CSS (static export)
|
||||
- **Backend**: Node.js Express API server with PostgreSQL database
|
||||
- **Infrastructure**: AWS (Amplify for frontend, EC2 for API, RDS for database), managed via Terraform
|
||||
Filamenteka is a 3D printing filament inventory management system for tracking Bambu Lab filaments. It consists of:
|
||||
- **Frontend**: Next.js app with React, TypeScript, and Tailwind CSS (static export to `/out`)
|
||||
- **Backend**: Node.js Express API server (`/api/server.js`) with PostgreSQL database
|
||||
- **Infrastructure**: AWS (CloudFront + S3 for frontend, EC2 for API, RDS for database)
|
||||
- **Repository**: `git.demirix.dev/dax/Filamenteka`
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- NEVER mention ANY author in commits. No author tags or attribution
|
||||
- NEVER mention AI/assistant names anywhere
|
||||
- Keep commit messages clean and professional with NO attribution
|
||||
- NEVER mention ANY author in commits. No author tags or attribution of any kind
|
||||
- Build for AMD64 Linux when deploying (development is on ARM macOS)
|
||||
- Always run security checks before commits
|
||||
- NEVER use mock data - all tests must use real APIs/data
|
||||
- Always run security checks before commits (Husky pre-commit hook does this automatically)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start Next.js development server (port 3000)
|
||||
npm run build # Build static export to /out directory
|
||||
npm run lint # Run ESLint
|
||||
npm test # Run Jest tests (includes no-mock-data validation)
|
||||
npm run dev # Start Next.js dev server (port 3000)
|
||||
npm run build # Build static export to /out
|
||||
npm run lint # ESLint
|
||||
npm test # Jest tests
|
||||
npm run test:watch # Jest watch mode
|
||||
npm test -- --testPathPattern=__tests__/components/ColorCell # Run single test file
|
||||
|
||||
# Security & Quality
|
||||
npm run security:check # Check for credential leaks
|
||||
npm run test:build # Test if build succeeds
|
||||
./scripts/pre-commit.sh # Full pre-commit validation (security, build, tests)
|
||||
# API Development
|
||||
cd api && npm run dev # Start API with nodemon (port 4000)
|
||||
|
||||
# Database Migrations
|
||||
npm run migrate # Run pending migrations
|
||||
npm run migrate:clear # Clear migration history
|
||||
# Quality Gates
|
||||
npm run security:check # Credential leak detection
|
||||
npm run test:build # Verify build succeeds without deploying
|
||||
npx tsc --noEmit # TypeScript type checking
|
||||
|
||||
# API Server Development
|
||||
cd api && npm start # Start API server locally (port 3001)
|
||||
cd api && npm run dev # Start API server with nodemon
|
||||
# Database
|
||||
npm run migrate # Run pending migrations locally
|
||||
scripts/update-db-via-aws.sh # Run migrations on production RDS via EC2
|
||||
|
||||
# Deployment
|
||||
./scripts/deploy-api.sh # Deploy API to EC2 (builds AMD64 Docker image)
|
||||
# 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
|
||||
|
||||
### Frontend Structure (Next.js App Router)
|
||||
### Frontend (Next.js App Router)
|
||||
- `/app/page.tsx` — Public filament inventory table (home page)
|
||||
- `/app/upadaj/` — Admin panel: login, `/dashboard` (filament CRUD), `/colors` (color management), `/requests` (customer color requests)
|
||||
- `/src/services/api.ts` — Axios instance with auth interceptors (auto token injection, 401/403 redirect)
|
||||
- `/src/data/bambuLabColors.ts` — Primary color-to-hex mapping with gradient support (used by `ColorCell` component)
|
||||
- `/src/data/bambuLabColorsComplete.ts` — Extended color database grouped by finish type (used by filters)
|
||||
|
||||
### API (`/api/server.js`)
|
||||
Single-file Express server running on EC2 (port 80). All routes inline.
|
||||
- Endpoints: `/api/login`, `/api/filaments`, `/api/colors`, `/api/sale/bulk`, `/api/color-requests`
|
||||
- Auth: JWT tokens with 24h expiry, single admin user (password from `ADMIN_PASSWORD` env var)
|
||||
|
||||
### Database (PostgreSQL on RDS)
|
||||
Key schemas and constraints:
|
||||
|
||||
```
|
||||
/app
|
||||
├── page.tsx # Public filament inventory table
|
||||
├── layout.tsx # Root layout with providers
|
||||
└── upadaj/ # Admin panel (JWT protected)
|
||||
├── page.tsx # Admin login
|
||||
├── dashboard/page.tsx # Filament CRUD operations
|
||||
├── colors/page.tsx # Color management
|
||||
└── requests/page.tsx # Color request management
|
||||
filaments: id, tip (material), finish, boja (color), refill, spulna, kolicina, cena, sale_*
|
||||
colors: id, name, hex, cena_refill (default 3499), cena_spulna (default 3999)
|
||||
color_requests: id, color_name, message, contact_name, contact_phone, status
|
||||
```
|
||||
|
||||
### API Structure
|
||||
```
|
||||
/api
|
||||
├── server.js # Express server (port 3001)
|
||||
├── routes/
|
||||
│ ├── filaments.js # Filament CRUD + bulk operations
|
||||
│ ├── colors.js # Color management
|
||||
│ ├── colorRequests.js # Color request system
|
||||
│ └── auth.js # JWT authentication
|
||||
└── middleware/
|
||||
└── auth.js # JWT verification middleware
|
||||
```
|
||||
**Critical constraints:**
|
||||
- `filaments.boja` → FK to `colors.name` (ON UPDATE CASCADE) — colors must exist before filaments reference them
|
||||
- `kolicina = refill + spulna` — enforced by check constraint
|
||||
- Migrations in `/database/migrations/` with sequential numbering (currently up to `020_`)
|
||||
|
||||
### Key Components
|
||||
- `FilamentTableV2` - Main inventory display with sorting/filtering/searching
|
||||
- `SaleManager` - Bulk sale management with countdown timers
|
||||
- `ColorCell` - Smart color rendering with gradient support
|
||||
- `EnhancedFilters` - Multi-criteria filtering system
|
||||
- `ColorRequestModal` - Public color request form
|
||||
## Key Patterns
|
||||
|
||||
### Data Models
|
||||
### Color Management (two-layer system)
|
||||
1. **Database `colors` table**: Defines valid color names + pricing. Must be populated first due to FK constraint.
|
||||
2. **Frontend `bambuLabColors.ts`**: Maps color names → hex codes for UI display. Supports solid (`hex: '#FF0000'`) and gradient (`hex: ['#HEX1', '#HEX2'], isGradient: true`).
|
||||
3. Color names must match exactly between `colors.name`, `filaments.boja`, and `bambuLabColors.ts` keys.
|
||||
4. The `ColorCell` component renders table row backgrounds from these mappings.
|
||||
|
||||
#### Filament Schema (PostgreSQL)
|
||||
```sql
|
||||
filaments: {
|
||||
id: UUID PRIMARY KEY,
|
||||
tip: VARCHAR(50), # Material type (PLA, PETG, ABS)
|
||||
finish: VARCHAR(50), # Finish type (Basic, Matte, Silk)
|
||||
boja: VARCHAR(100), # Color name (links to colors.name)
|
||||
boja_hex: VARCHAR(7), # Color hex code
|
||||
refill: INTEGER, # Refill spool count
|
||||
spulna: INTEGER, # Regular spool count
|
||||
kolicina: INTEGER, # Total quantity (refill + spulna)
|
||||
cena: VARCHAR(50), # Price string
|
||||
sale_active: BOOLEAN, # Sale status
|
||||
sale_percentage: INTEGER,# Sale discount percentage
|
||||
sale_end_date: TIMESTAMP # Sale expiry
|
||||
}
|
||||
```
|
||||
|
||||
#### Color Schema
|
||||
```sql
|
||||
colors: {
|
||||
id: UUID PRIMARY KEY,
|
||||
name: VARCHAR(100) UNIQUE, # Color name (must match filament.boja)
|
||||
hex: VARCHAR(7), # Hex color code
|
||||
cena_refill: INTEGER, # Refill price (default: 3499)
|
||||
cena_spulna: INTEGER # Regular price (default: 3999)
|
||||
}
|
||||
```
|
||||
|
||||
#### Color Requests Schema
|
||||
```sql
|
||||
color_requests: {
|
||||
id: UUID PRIMARY KEY,
|
||||
color_name: VARCHAR(100),
|
||||
email: VARCHAR(255),
|
||||
message: TEXT,
|
||||
status: VARCHAR(50), # pending/approved/rejected
|
||||
created_at: TIMESTAMP
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Frontend (AWS Amplify)
|
||||
- Automatic deployment on push to main branch
|
||||
- Build output: Static files in `/out` directory
|
||||
- Build command: `npm run build`
|
||||
- Config: `amplify.yml`, `next.config.js` (output: 'export')
|
||||
|
||||
### API Server (EC2)
|
||||
- Manual deployment via `./scripts/deploy-api.sh`
|
||||
- Docker containerized (Node.js 18 Alpine)
|
||||
- Server: `3.71.161.51`
|
||||
- Domain: `api.filamenteka.rs`
|
||||
- **Important**: Build for AMD64 architecture when deploying from ARM Mac
|
||||
|
||||
### Database (RDS PostgreSQL)
|
||||
- Host: `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com`
|
||||
- Migrations in `/database/migrations/` (numbered sequence)
|
||||
- Schema in `/database/schema.sql`
|
||||
- Connection via `DATABASE_URL` environment variable
|
||||
|
||||
## Important Patterns
|
||||
### Adding New Colors
|
||||
1. Add to database `colors` table (via admin panel at `/upadaj/colors` or migration)
|
||||
2. Add hex mapping to `src/data/bambuLabColors.ts`
|
||||
3. Optionally add to `src/data/bambuLabColorsComplete.ts` finish groups
|
||||
|
||||
### API Communication
|
||||
- All API calls use axios with interceptors (`src/services/api.ts`)
|
||||
- Auth token stored in localStorage as 'adminToken'
|
||||
- Automatic redirect on 401/403 in admin routes
|
||||
- Base URL from `NEXT_PUBLIC_API_URL` env variable
|
||||
- Service modules in `src/services/api.ts`: `authService`, `colorService`, `filamentService`, `colorRequestService`
|
||||
- Auth token stored in localStorage with 24h expiry
|
||||
- Cache busting on filament fetches via timestamp query param
|
||||
|
||||
### Color Management
|
||||
- Primary source: `src/data/bambuLabColors.ts` (hex mappings)
|
||||
- Extended data: `src/data/bambuLabColorsComplete.ts`
|
||||
- Refill-only colors: `src/data/refillOnlyColors.ts`
|
||||
- Automatic row coloring based on filament.boja_hex
|
||||
- Special gradient handling for multi-color filaments
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
### State Management
|
||||
- React hooks for local state (no global state library)
|
||||
- Data fetching with useEffect in components
|
||||
- Form state managed with controlled components
|
||||
- Admin auth state in localStorage
|
||||
`.gitea/workflows/deploy.yml` triggers on push to `main`. Auto-detects changes via `git diff HEAD~1`:
|
||||
- **Frontend changes** (anything outside `api/`): security check → tests → build → S3 deploy (3-tier cache: HTML no-cache, `_next/` immutable, rest 24h) → CloudFront invalidation
|
||||
- **API changes** (`api/` files): SSM command to EC2 to pull `server.js` from Gitea and restart `node-api` service
|
||||
- Both run if both changed in a single push
|
||||
|
||||
### Testing Strategy
|
||||
- Jest + React Testing Library for component tests
|
||||
- Special `no-mock-data.test.ts` enforces real API usage
|
||||
- Integration tests connect to real database
|
||||
- Coverage reports in `/coverage/`
|
||||
- Test files in `__tests__/` directories
|
||||
## Deployment Details
|
||||
|
||||
| Component | Target | Method |
|
||||
|-----------|--------|--------|
|
||||
| Frontend | S3 `filamenteka-frontend` → CloudFront | Static export, OAC-protected |
|
||||
| API | EC2 `i-03956ecf32292d7d9` | SSM, systemd `node-api` service |
|
||||
| Database | RDS `filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com` | Migrations via `scripts/update-db-via-aws.sh` |
|
||||
| DNS | Cloudflare | `api.filamenteka.rs` → EC2 |
|
||||
| IaC | `/terraform/` | VPC, EC2, RDS, ALB, CloudFront, Cloudflare |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Frontend (.env.local)
|
||||
NEXT_PUBLIC_API_URL=https://api.filamenteka.rs/api
|
||||
NEXT_PUBLIC_MATOMO_URL=https://analytics.demirix.dev
|
||||
NEXT_PUBLIC_MATOMO_SITE_ID=7
|
||||
|
||||
# API Server (.env)
|
||||
DATABASE_URL=postgresql://username:password@host/database
|
||||
JWT_SECRET=your-secret-key
|
||||
# API Server (.env in /api)
|
||||
DATABASE_URL=postgresql://filamenteka_admin:PASSWORD@filamenteka.ci7fsdlbzmag.eu-central-1.rds.amazonaws.com:5432/filamenteka
|
||||
JWT_SECRET=...
|
||||
ADMIN_PASSWORD=...
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
PORT=80
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
## Pre-commit Hooks (Husky)
|
||||
|
||||
- JWT authentication for admin routes (24h expiry)
|
||||
- Password hashing with bcryptjs (10 rounds)
|
||||
- SQL injection prevention via parameterized queries
|
||||
- Credential leak detection in pre-commit hooks
|
||||
- CORS configured for production domains only
|
||||
- Author mention detection prevents attribution in commits
|
||||
|
||||
## Database Operations
|
||||
|
||||
When modifying the database:
|
||||
1. Create numbered migration file in `/database/migrations/`
|
||||
2. Test locally with `npm run migrate`
|
||||
3. Deploy to production via SSH or migration script
|
||||
4. Update TypeScript interfaces in `src/types/`
|
||||
5. Update relevant data files in `src/data/`
|
||||
|
||||
## Terraform Infrastructure
|
||||
|
||||
Infrastructure as Code in `/terraform/`:
|
||||
- VPC with public/private subnets
|
||||
- EC2 instance with Application Load Balancer
|
||||
- RDS PostgreSQL instance
|
||||
- ECR for Docker image registry
|
||||
- Cloudflare DNS integration
|
||||
- Environment separation (dev/prod)
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### ARM to AMD64 Builds
|
||||
When deploying from ARM Mac to AMD64 Linux:
|
||||
- Docker builds must specify `--platform linux/amd64`
|
||||
- Deployment scripts handle architecture conversion
|
||||
- Test builds locally with `docker buildx`
|
||||
|
||||
### Color Data Synchronization
|
||||
- Colors must exist in both database and frontend data files
|
||||
- Use migration scripts to sync color data
|
||||
- Validate consistency with data consistency tests
|
||||
|
||||
### Sale Management
|
||||
- Bulk sale operations affect multiple filaments
|
||||
- Sale countdown timers update in real-time
|
||||
- Prices automatically calculated with discounts
|
||||
- Sale end dates trigger automatic deactivation
|
||||
`scripts/pre-commit.sh` runs automatically and blocks commits that fail:
|
||||
1. Author mention check (blocks attribution)
|
||||
2. Security check (credential leaks)
|
||||
3. Build test (ensures compilation)
|
||||
4. Unit tests (`jest --passWithNoTests`)
|
||||
|
||||
55
README.md
55
README.md
@@ -4,21 +4,22 @@ A web application for tracking Bambu Lab filament inventory with automatic color
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 **Automatic Color Coding** - Table rows are automatically colored based on filament colors
|
||||
- 🔍 **Search & Filter** - Quick search across all filament properties
|
||||
- 📊 **Sortable Columns** - Click headers to sort by any column
|
||||
- 🌈 **Gradient Support** - Special handling for gradient filaments like Cotton Candy Cloud
|
||||
- 📱 **Responsive Design** - Works on desktop and mobile devices
|
||||
- 💰 **Sale Management** - Bulk sale pricing with countdown timers
|
||||
- 🔐 **Admin Panel** - Protected dashboard for inventory management
|
||||
- 📦 **Spool Types** - Support for both regular and refill spools
|
||||
- Automatic Color Coding - Table rows are automatically colored based on filament colors
|
||||
- Search & Filter - Quick search across all filament properties
|
||||
- Sortable Columns - Click headers to sort by any column
|
||||
- Gradient Support - Special handling for gradient filaments like Cotton Candy Cloud
|
||||
- Responsive Design - Works on desktop and mobile devices
|
||||
- Sale Management - Bulk sale pricing with countdown timers
|
||||
- Admin Panel - Protected dashboard for inventory management
|
||||
- Spool Types - Support for both regular and refill spools
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Next.js + React + TypeScript + Tailwind CSS
|
||||
- **Backend**: Node.js API server (Express)
|
||||
- **Database**: PostgreSQL (AWS RDS)
|
||||
- **Infrastructure**: AWS Amplify (Frontend), EC2 (API), RDS (Database)
|
||||
- **Infrastructure**: AWS CloudFront + S3 (Frontend), EC2 (API), RDS (Database)
|
||||
- **CI/CD**: Gitea Actions
|
||||
- **IaC**: Terraform
|
||||
|
||||
## Prerequisites
|
||||
@@ -27,15 +28,14 @@ A web application for tracking Bambu Lab filament inventory with automatic color
|
||||
- PostgreSQL (for local development)
|
||||
- AWS Account (for deployment)
|
||||
- Terraform 1.0+ (for infrastructure)
|
||||
- GitHub account
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/filamenteka.git
|
||||
cd filamenteka
|
||||
git clone https://git.demirix.dev/DaX/Filamenteka.git
|
||||
cd Filamenteka
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
@@ -90,7 +90,7 @@ The filament table displays these columns:
|
||||
- **Boja** - Color name (e.g., Mistletoe Green, Hot Pink)
|
||||
- **Refill** - Number of refill spools
|
||||
- **Spulna** - Number of regular spools
|
||||
- **Količina** - Total quantity (refill + spulna)
|
||||
- **Kolicina** - Total quantity (refill + spulna)
|
||||
- **Cena** - Price per unit
|
||||
- **Sale** - Active sale percentage and end date
|
||||
|
||||
@@ -106,15 +106,19 @@ Unknown colors default to light gray.
|
||||
|
||||
## 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
|
||||
git add .
|
||||
git commit -m "Update filament colors"
|
||||
git push origin main
|
||||
```
|
||||
# Frontend
|
||||
./scripts/deploy-frontend.sh
|
||||
|
||||
Amplify will automatically build and deploy your changes.
|
||||
# API
|
||||
./scripts/deploy-api-update.sh
|
||||
```
|
||||
|
||||
## Admin Panel
|
||||
|
||||
@@ -155,9 +159,7 @@ Deploy API server updates:
|
||||
|
||||
```bash
|
||||
# Use the deployment script
|
||||
./scripts/deploy-api.sh
|
||||
|
||||
# Or deploy manually to EC2 instance
|
||||
./scripts/deploy-api-update.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -181,12 +183,3 @@ Deploy API server updates:
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('No Mock Data Tests', () => {
|
||||
|
||||
it('should use API service in all components', () => {
|
||||
const pagePath = join(process.cwd(), 'app', 'page.tsx');
|
||||
const adminPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
|
||||
const pageContent = readFileSync(pagePath, 'utf-8');
|
||||
const adminContent = readFileSync(adminPath, 'utf-8');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path';
|
||||
|
||||
describe('UI Features Tests', () => {
|
||||
it('should have color hex input in admin form', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for color input
|
||||
@@ -22,19 +22,19 @@ describe('UI Features Tests', () => {
|
||||
});
|
||||
|
||||
it('should have number inputs for quantity fields', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for number inputs for quantities
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="refill"/);
|
||||
expect(adminContent).toMatch(/type="number"[\s\S]*?name="spulna"/);
|
||||
expect(adminContent).toContain('Refill');
|
||||
expect(adminContent).toContain('Spulna');
|
||||
expect(adminContent).toContain('Refil');
|
||||
expect(adminContent).toContain('Špulna');
|
||||
expect(adminContent).toContain('Ukupna količina');
|
||||
});
|
||||
|
||||
it('should have number input for quantity', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for number input
|
||||
@@ -44,7 +44,7 @@ describe('UI Features Tests', () => {
|
||||
});
|
||||
|
||||
it('should have predefined material options', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
const adminContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
// Check for material select dropdown
|
||||
@@ -54,7 +54,7 @@ describe('UI Features Tests', () => {
|
||||
});
|
||||
|
||||
it('should have admin header with navigation', () => {
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'upadaj', 'dashboard', 'page.tsx');
|
||||
const adminDashboardPath = join(process.cwd(), 'app', 'dashboard', 'page.tsx');
|
||||
|
||||
const dashboardContent = readFileSync(adminDashboardPath, 'utf-8');
|
||||
|
||||
|
||||
18
amplify.yml
18
amplify.yml
@@ -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
257
api/package-lock.json
generated
@@ -79,23 +79,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
@@ -224,24 +224,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
@@ -249,6 +249,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@@ -280,9 +284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -375,39 +379,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -434,17 +438,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -590,19 +594,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -686,12 +694,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
@@ -714,9 +722,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
@@ -725,12 +733,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -874,9 +882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -903,9 +911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -986,14 +994,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.2",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz",
|
||||
"integrity": "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.2",
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
"pg-protocol": "^1.11.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
@@ -1001,7 +1009,7 @@
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.6"
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
@@ -1013,16 +1021,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.6.tgz",
|
||||
"integrity": "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
@@ -1035,18 +1043,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.2.tgz",
|
||||
"integrity": "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
@@ -1097,9 +1105,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -1147,12 +1155,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -1171,15 +1179,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -1225,9 +1233,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1237,38 +1245,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1276,15 +1275,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
@@ -1391,9 +1390,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
|
||||
418
api/server.js
418
api/server.js
@@ -161,17 +161,36 @@ app.put('/api/filaments/:id', authenticateToken, async (req, res) => {
|
||||
const spulnaNum = parseInt(spulna) || 0;
|
||||
const kolicina = refillNum + spulnaNum;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE filaments
|
||||
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||
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]
|
||||
);
|
||||
// Check if sale fields are provided in the request
|
||||
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
||||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
||||
|
||||
let result;
|
||||
if (hasSaleFields) {
|
||||
// Update with sale fields if they are provided
|
||||
result = await pool.query(
|
||||
`UPDATE filaments
|
||||
SET tip = $1, finish = $2, boja = $3, boja_hex = $4,
|
||||
refill = $5, spulna = $6, kolicina = $7, cena = $8,
|
||||
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]);
|
||||
} catch (error) {
|
||||
console.error('Error updating filament:', error);
|
||||
@@ -366,6 +385,383 @@ app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Slug generation helper
|
||||
const generateSlug = (name) => {
|
||||
const base = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const suffix = Math.random().toString(36).substring(2, 8);
|
||||
return `${base}-${suffix}`;
|
||||
};
|
||||
|
||||
// Products endpoints
|
||||
|
||||
// List/filter products (PUBLIC)
|
||||
app.get('/api/products', async (req, res) => {
|
||||
try {
|
||||
const { category, condition, printer_model, in_stock, search } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (category) {
|
||||
conditions.push(`p.category = $${paramIndex++}`);
|
||||
params.push(category);
|
||||
}
|
||||
if (condition) {
|
||||
conditions.push(`p.condition = $${paramIndex++}`);
|
||||
params.push(condition);
|
||||
}
|
||||
if (printer_model) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM product_printer_compatibility ppc
|
||||
JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++}
|
||||
)`);
|
||||
params.push(printer_model);
|
||||
}
|
||||
if (in_stock === 'true') {
|
||||
conditions.push(`p.quantity > 0`);
|
||||
} else if (in_stock === 'false') {
|
||||
conditions.push(`p.quantity = 0`);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
||||
) FILTER (WHERE pm.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS compatible_printers
|
||||
FROM products p
|
||||
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
||||
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
${whereClause}
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch products' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single product (PUBLIC)
|
||||
app.get('/api/products/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series)
|
||||
) FILTER (WHERE pm.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS compatible_printers
|
||||
FROM products p
|
||||
LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id
|
||||
LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create product (auth required)
|
||||
app.post('/api/products', authenticateToken, async (req, res) => {
|
||||
const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body;
|
||||
|
||||
try {
|
||||
const slug = generateSlug(name);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
// Insert printer compatibility entries if provided
|
||||
if (printer_model_ids && printer_model_ids.length > 0) {
|
||||
const compatValues = printer_model_ids
|
||||
.map((_, i) => `($1, $${i + 2})`)
|
||||
.join(', ');
|
||||
await pool.query(
|
||||
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
||||
[product.id, ...printer_model_ids]
|
||||
);
|
||||
}
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.error('Error creating product:', error);
|
||||
res.status(500).json({ error: 'Failed to create product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update product (auth required)
|
||||
app.put('/api/products/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body;
|
||||
|
||||
try {
|
||||
const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body ||
|
||||
'sale_start_date' in req.body || 'sale_end_date' in req.body;
|
||||
|
||||
let result;
|
||||
if (hasSaleFields) {
|
||||
result = await pool.query(
|
||||
`UPDATE products
|
||||
SET name = $1, description = $2, category = $3, condition = $4,
|
||||
price = $5, quantity = $6, image_url = $7,
|
||||
sale_percentage = $8, sale_active = $9,
|
||||
sale_start_date = $10, sale_end_date = $11,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $12 RETURNING *`,
|
||||
[name, description, category, condition, price, parseInt(quantity) || 0, image_url,
|
||||
sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id]
|
||||
);
|
||||
} else {
|
||||
result = await pool.query(
|
||||
`UPDATE products
|
||||
SET name = $1, description = $2, category = $3, condition = $4,
|
||||
price = $5, quantity = $6, image_url = $7,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[name, description, category, condition, price, parseInt(quantity) || 0, image_url, id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Update printer compatibility if provided
|
||||
if (printer_model_ids !== undefined) {
|
||||
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
||||
|
||||
if (printer_model_ids && printer_model_ids.length > 0) {
|
||||
const compatValues = printer_model_ids
|
||||
.map((_, i) => `($1, $${i + 2})`)
|
||||
.join(', ');
|
||||
await pool.query(
|
||||
`INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`,
|
||||
[id, ...printer_model_ids]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error updating product:', error);
|
||||
res.status(500).json({ error: 'Failed to update product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete product (auth required)
|
||||
app.delete('/api/products/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]);
|
||||
const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting product:', error);
|
||||
res.status(500).json({ error: 'Failed to delete product' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk sale update for products (auth required)
|
||||
app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => {
|
||||
const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body;
|
||||
|
||||
try {
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (productIds && productIds.length > 0) {
|
||||
query = `
|
||||
UPDATE products
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY($5)
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds];
|
||||
} else {
|
||||
query = `
|
||||
UPDATE products
|
||||
SET sale_percentage = $1,
|
||||
sale_active = $2,
|
||||
sale_start_date = $3,
|
||||
sale_end_date = $4,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`;
|
||||
params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json({
|
||||
success: true,
|
||||
updatedCount: result.rowCount,
|
||||
updatedProducts: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating product sale:', error);
|
||||
res.status(500).json({ error: 'Failed to update product sale' });
|
||||
}
|
||||
});
|
||||
|
||||
// Printer models endpoints
|
||||
|
||||
// List all printer models (PUBLIC)
|
||||
app.get('/api/printer-models', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching printer models:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch printer models' });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics endpoints
|
||||
|
||||
// Aggregated inventory stats (auth required)
|
||||
app.get('/api/analytics/inventory', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const filamentStats = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_skus,
|
||||
COALESCE(SUM(kolicina), 0) AS total_units,
|
||||
COALESCE(SUM(refill), 0) AS total_refills,
|
||||
COALESCE(SUM(spulna), 0) AS total_spools,
|
||||
COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus,
|
||||
COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value
|
||||
FROM filaments
|
||||
`);
|
||||
|
||||
const productStats = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total_skus,
|
||||
COALESCE(SUM(quantity), 0) AS total_units,
|
||||
COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus,
|
||||
COALESCE(SUM(quantity * price), 0) AS total_inventory_value,
|
||||
COUNT(*) FILTER (WHERE category = 'printer') AS printers,
|
||||
COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates,
|
||||
COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles,
|
||||
COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts,
|
||||
COUNT(*) FILTER (WHERE category = 'accessory') AS accessories
|
||||
FROM products
|
||||
`);
|
||||
|
||||
const filament = filamentStats.rows[0];
|
||||
const product = productStats.rows[0];
|
||||
|
||||
res.json({
|
||||
filaments: {
|
||||
total_skus: parseInt(filament.total_skus),
|
||||
total_units: parseInt(filament.total_units),
|
||||
total_refills: parseInt(filament.total_refills),
|
||||
total_spools: parseInt(filament.total_spools),
|
||||
out_of_stock_skus: parseInt(filament.out_of_stock_skus),
|
||||
total_inventory_value: parseInt(filament.total_inventory_value)
|
||||
},
|
||||
products: {
|
||||
total_skus: parseInt(product.total_skus),
|
||||
total_units: parseInt(product.total_units),
|
||||
out_of_stock_skus: parseInt(product.out_of_stock_skus),
|
||||
total_inventory_value: parseInt(product.total_inventory_value),
|
||||
by_category: {
|
||||
printers: parseInt(product.printers),
|
||||
build_plates: parseInt(product.build_plates),
|
||||
nozzles: parseInt(product.nozzles),
|
||||
spare_parts: parseInt(product.spare_parts),
|
||||
accessories: parseInt(product.accessories)
|
||||
}
|
||||
},
|
||||
combined: {
|
||||
total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus),
|
||||
total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value),
|
||||
out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch inventory analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Active sales overview (auth required)
|
||||
app.get('/api/analytics/sales', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const filamentSales = await pool.query(`
|
||||
SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
||||
ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price
|
||||
FROM filaments
|
||||
WHERE sale_active = true
|
||||
ORDER BY sale_percentage DESC
|
||||
`);
|
||||
|
||||
const productSales = await pool.query(`
|
||||
SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date,
|
||||
ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price
|
||||
FROM products
|
||||
WHERE sale_active = true
|
||||
ORDER BY sale_percentage DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
filaments: {
|
||||
count: filamentSales.rowCount,
|
||||
items: filamentSales.rows
|
||||
},
|
||||
products: {
|
||||
count: productSales.rowCount,
|
||||
items: productSales.rows
|
||||
},
|
||||
total_active_sales: filamentSales.rowCount + productSales.rowCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch sales analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
1104
app/dashboard/page.tsx
Normal file
1104
app/dashboard/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
18
app/delovi/layout.tsx
Normal file
18
app/delovi/layout.tsx
Normal 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
43
app/delovi/page.tsx
Normal 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
18
app/filamenti/layout.tsx
Normal 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
43
app/filamenti/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,27 @@ import { BackToTop } from '../src/components/BackToTop'
|
||||
import { MatomoAnalytics } from '../src/components/MatomoAnalytics'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Filamenteka',
|
||||
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
|
||||
metadataBase: new URL('https://filamenteka.rs'),
|
||||
title: {
|
||||
default: 'Bambu Lab Oprema Srbija | Filamenteka',
|
||||
template: '%s | Filamenteka',
|
||||
},
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji. Filamenti, 3D stampaci, build plate podloge, mlaznice, rezervni delovi i oprema.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'sr_RS',
|
||||
siteName: 'Filamenteka',
|
||||
title: 'Bambu Lab Oprema Srbija | Filamenteka',
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji.',
|
||||
url: 'https://filamenteka.rs',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: 'https://filamenteka.rs',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -13,27 +32,40 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Filamenteka',
|
||||
description: 'Privatna prodaja originalne Bambu Lab opreme u Srbiji',
|
||||
url: 'https://filamenteka.rs',
|
||||
telephone: '+381631031048',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressCountry: 'RS',
|
||||
},
|
||||
priceRange: 'RSD',
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="sr" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
document.documentElement.classList.add('no-transitions');
|
||||
// Apply dark mode immediately for admin pages
|
||||
if (window.location.pathname.startsWith('/upadaj')) {
|
||||
if (window.location.pathname.startsWith('/upadaj') || window.location.pathname.startsWith('/dashboard')) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
// For non-admin pages, check localStorage
|
||||
try {
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
var darkMode = localStorage.getItem('darkMode');
|
||||
if (darkMode === 'true') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
// Remove no-transitions class after a short delay
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
document.documentElement.classList.remove('no-transitions');
|
||||
@@ -43,6 +75,10 @@ export default function RootLayout({
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
|
||||
18
app/mlaznice/layout.tsx
Normal file
18
app/mlaznice/layout.tsx
Normal 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
43
app/mlaznice/page.tsx
Normal 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
18
app/oprema/layout.tsx
Normal 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
43
app/oprema/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
591
app/page.tsx
591
app/page.tsx
@@ -1,277 +1,384 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FilamentTableV2 } from '../src/components/FilamentTableV2';
|
||||
import { SaleCountdown } from '../src/components/SaleCountdown';
|
||||
import TabbedNavigation from '../src/components/TabbedNavigation';
|
||||
import PrintersTable from '../src/components/PrintersTable';
|
||||
import GearTable from '../src/components/GearTable';
|
||||
import ColorRequestSection from '../src/components/ColorRequestSection';
|
||||
import { Filament } from '../src/types/filament';
|
||||
import { filamentService } from '../src/services/api';
|
||||
import { trackEvent } from '../src/components/MatomoAnalytics';
|
||||
import Link from 'next/link';
|
||||
import { SiteHeader } from '@/src/components/layout/SiteHeader';
|
||||
import { SiteFooter } from '@/src/components/layout/SiteFooter';
|
||||
import { SaleCountdown } from '@/src/components/SaleCountdown';
|
||||
import ColorRequestModal from '@/src/components/ColorRequestModal';
|
||||
import { CategoryIcon } from '@/src/components/ui/CategoryIcon';
|
||||
import { getEnabledCategories } from '@/src/config/categories';
|
||||
import { filamentService } from '@/src/services/api';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { trackEvent } from '@/src/components/MatomoAnalytics';
|
||||
|
||||
const KP_URL = 'https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246';
|
||||
|
||||
export default function Home() {
|
||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [showColorRequestModal, setShowColorRequestModal] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState('filaments');
|
||||
// Removed V1/V2 toggle - now only using V2
|
||||
|
||||
// Initialize dark mode from localStorage after mounting
|
||||
const categories = getEnabledCategories();
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved) {
|
||||
setDarkMode(JSON.parse(saved));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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');
|
||||
const fetchFilaments = async () => {
|
||||
try {
|
||||
const data = await filamentService.getAll();
|
||||
setFilaments(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch filaments for sale check:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
};
|
||||
fetchFilaments();
|
||||
|
||||
// Auto-refresh data every 30 seconds to stay in sync
|
||||
const interval = setInterval(() => {
|
||||
fetchFilaments();
|
||||
}, 30000);
|
||||
|
||||
// Also refresh when window regains focus
|
||||
const handleFocus = () => {
|
||||
fetchFilaments();
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
setResetKey(prev => prev + 1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
const activeSaleFilaments = filaments.filter(f => f.sale_active === true);
|
||||
const hasActiveSale = activeSaleFilaments.length > 0;
|
||||
const maxSalePercentage = hasActiveSale
|
||||
? Math.max(...activeSaleFilaments.map(f => f.sale_percentage || 0), 0)
|
||||
: 0;
|
||||
const saleEndDate = (() => {
|
||||
const withEndDate = activeSaleFilaments.filter(f => f.sale_end_date);
|
||||
if (withEndDate.length === 0) return null;
|
||||
return withEndDate.reduce((latest, current) => {
|
||||
if (!latest.sale_end_date) return current;
|
||||
if (!current.sale_end_date) return latest;
|
||||
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
|
||||
}).sale_end_date ?? null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<header className="bg-gradient-to-r from-blue-50 to-orange-50 dark:from-gray-800 dark:to-gray-900 shadow-lg transition-all duration-300">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
|
||||
<div className="flex-1 flex flex-col sm:flex-row justify-center items-center gap-1 sm:gap-3 text-sm sm:text-lg">
|
||||
<span className="text-blue-700 dark:text-blue-300 font-medium animate-pulse text-center">
|
||||
Kupovina po gramu dostupna
|
||||
</span>
|
||||
<span className="hidden sm:inline text-gray-400 dark:text-gray-600">•</span>
|
||||
<span className="text-orange-700 dark:text-orange-300 font-medium animate-pulse text-center">
|
||||
Popust za 5+ komada
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-screen" style={{ background: 'var(--surface-primary)' }}>
|
||||
<SiteHeader />
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
{mounted ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDarkMode(!darkMode);
|
||||
trackEvent('UI', 'Dark Mode Toggle', darkMode ? 'Light' : 'Dark');
|
||||
}}
|
||||
className="p-2 bg-white/50 dark:bg-gray-700/50 backdrop-blur text-gray-800 dark:text-gray-200 rounded-full hover:bg-white/80 dark:hover:bg-gray-600/80 transition-all duration-200 shadow-md"
|
||||
title={darkMode ? 'Svetla tema' : 'Tamna tema'}
|
||||
>
|
||||
{darkMode ? '☀️' : '🌙'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="relative overflow-hidden noise-overlay">
|
||||
{/* Rich multi-layer gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-950 via-purple-950 to-indigo-950" />
|
||||
|
||||
{/* Floating color orbs — big, bright, energetic */}
|
||||
<div className="absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute w-[600px] h-[600px] -top-48 -left-48 rounded-full opacity-40 blur-[100px] animate-float"
|
||||
style={{ background: 'radial-gradient(circle, #3b82f6, transparent)' }} />
|
||||
<div className="absolute w-[500px] h-[500px] top-10 right-0 rounded-full opacity-40 blur-[80px] animate-float-slow"
|
||||
style={{ background: 'radial-gradient(circle, #a855f7, transparent)' }} />
|
||||
<div className="absolute w-[450px] h-[450px] -bottom-24 left-1/3 rounded-full opacity-40 blur-[90px] animate-float-delayed"
|
||||
style={{ background: 'radial-gradient(circle, #f59e0b, transparent)' }} />
|
||||
<div className="absolute w-[400px] h-[400px] bottom-10 right-1/4 rounded-full opacity-40 blur-[80px] animate-float"
|
||||
style={{ background: 'radial-gradient(circle, #22c55e, transparent)' }} />
|
||||
<div className="absolute w-[350px] h-[350px] top-1/2 left-10 rounded-full opacity-35 blur-[70px] animate-float-slow"
|
||||
style={{ background: 'radial-gradient(circle, #ef4444, transparent)' }} />
|
||||
<div className="absolute w-[300px] h-[300px] top-1/4 right-1/3 rounded-full opacity-35 blur-[75px] animate-float-delayed"
|
||||
style={{ background: 'radial-gradient(circle, #06b6d4, transparent)' }} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Logo centered above content */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group transition-transform duration-200"
|
||||
title="Klikni za reset"
|
||||
>
|
||||
{/* Using next/image would cause preload, so we use regular img with loading="lazy" */}
|
||||
{/* Grid pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px'
|
||||
}} />
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-28 lg:py-32">
|
||||
<div className="flex flex-col items-center text-center reveal-up">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Filamenteka"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
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"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mb-8">
|
||||
<a
|
||||
href="https://www.kupujemprodajem.com/kompjuteri-desktop/3d-stampaci-i-oprema/originalni-bambu-lab-filamenti-na-stanju/oglas/182256246"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Homepage')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Kupi na Kupujem Prodajem
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="tel:+381677102845"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Homepage')}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
Pozovi +381 67 710 2845
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="mb-8">
|
||||
<TabbedNavigation
|
||||
tabs={[
|
||||
{
|
||||
id: 'filaments',
|
||||
label: 'Filamenti',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'printers',
|
||||
label: 'Štampači',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'gear',
|
||||
label: 'Oprema',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'filaments' && (
|
||||
<>
|
||||
<SaleCountdown
|
||||
hasActiveSale={filaments.some(f => f.sale_active === true)}
|
||||
maxSalePercentage={Math.max(...filaments.filter(f => f.sale_active === true).map(f => f.sale_percentage || 0), 0)}
|
||||
saleEndDate={(() => {
|
||||
const activeSales = filaments.filter(f => f.sale_active === true && f.sale_end_date);
|
||||
if (activeSales.length === 0) return null;
|
||||
const latestSale = activeSales.reduce((latest, current) => {
|
||||
if (!latest.sale_end_date) return current;
|
||||
if (!current.sale_end_date) return latest;
|
||||
return new Date(current.sale_end_date) > new Date(latest.sale_end_date) ? current : latest;
|
||||
}).sale_end_date;
|
||||
return latestSale;
|
||||
})()}
|
||||
className="h-24 sm:h-32 md:h-40 w-auto drop-shadow-2xl mb-8"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
|
||||
<FilamentTableV2
|
||||
key={resetKey}
|
||||
filaments={filaments}
|
||||
/>
|
||||
{/* Tagline */}
|
||||
<h1
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-white mb-5 tracking-tight leading-[1.1]"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
Bambu Lab oprema
|
||||
<br />
|
||||
<span className="hero-gradient-text">
|
||||
u Srbiji
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-300 max-w-xl mb-10 leading-relaxed reveal-up reveal-delay-1">
|
||||
Filamenti, stampaci, podloge, mlaznice, delovi i oprema.
|
||||
<br className="hidden sm:block" />
|
||||
Originalni proizvodi, privatna prodaja.
|
||||
</p>
|
||||
|
||||
{/* Color Request Section Below Table */}
|
||||
<div className="mt-16">
|
||||
<ColorRequestSection />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'printers' && <PrintersTable />}
|
||||
|
||||
{activeTab === 'gear' && <GearTable />}
|
||||
</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>
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 reveal-up reveal-delay-2">
|
||||
<a
|
||||
href="tel:+381677102845"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
onClick={() => trackEvent('Contact', 'Phone Call', 'Footer')}
|
||||
href={KP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackEvent('External Link', 'Kupujem Prodajem', 'Hero CTA')}
|
||||
className="group inline-flex items-center justify-center gap-2.5 px-8 py-4
|
||||
bg-blue-500 hover:bg-blue-600 text-white font-bold text-base rounded-2xl
|
||||
shadow-lg shadow-blue-500/25
|
||||
hover:shadow-2xl hover:shadow-blue-500/35
|
||||
transition-all duration-300 active:scale-[0.97]"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
<svg 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>
|
||||
+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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
18
app/ploce/layout.tsx
Normal file
18
app/ploce/layout.tsx
Normal 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
43
app/ploce/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
app/politika-privatnosti/page.tsx
Normal file
205
app/politika-privatnosti/page.tsx
Normal 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 "Do Not
|
||||
Track" 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 ("pravo na zaborav")</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
18
app/stampaci/layout.tsx
Normal 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
43
app/stampaci/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { colorService } from '@/src/services/api';
|
||||
import { bambuLabColors, getColorHex } from '@/src/data/bambuLabColorsComplete';
|
||||
import { BulkPriceEditor } from '@/src/components/BulkPriceEditor';
|
||||
import '@/src/styles/select.css';
|
||||
|
||||
interface Color {
|
||||
@@ -53,13 +54,16 @@ export default function ColorsManagement() {
|
||||
|
||||
// Check authentication
|
||||
useEffect(() => {
|
||||
// Wait for component to mount to avoid SSR issues with localStorage
|
||||
if (!mounted) return;
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || Date.now() > parseInt(expiry)) {
|
||||
router.push('/upadaj');
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
}, [router]);
|
||||
}, [mounted]);
|
||||
|
||||
// Fetch colors
|
||||
const fetchColors = async () => {
|
||||
@@ -176,22 +180,22 @@ export default function ColorsManagement() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">Učitavanje...</div>
|
||||
return <div className="min-h-screen bg-gray-50 dark:bg-[#060a14] flex items-center justify-center">
|
||||
<div className="text-gray-600 dark:text-white/40">Učitavanje...</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
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">
|
||||
{/* 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">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">Admin Panel</h2>
|
||||
<nav className="space-y-2">
|
||||
<a
|
||||
href="/upadaj/dashboard"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
href="/dashboard"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-white/60 hover:bg-gray-100 dark:hover:bg-white/[0.06] rounded"
|
||||
>
|
||||
Filamenti
|
||||
</a>
|
||||
@@ -207,7 +211,7 @@ export default function ColorsManagement() {
|
||||
|
||||
{/* Main Content */}
|
||||
<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="flex justify-between items-center">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{!showAddForm && !editingColor && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
@@ -227,6 +231,7 @@ export default function ColorsManagement() {
|
||||
Dodaj novu boju
|
||||
</button>
|
||||
)}
|
||||
<BulkPriceEditor colors={colors} onUpdate={fetchColors} />
|
||||
{selectedColors.size > 0 && (
|
||||
<button
|
||||
onClick={handleBulkDelete}
|
||||
@@ -244,7 +249,7 @@ export default function ColorsManagement() {
|
||||
{mounted && (
|
||||
<button
|
||||
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'}
|
||||
>
|
||||
{darkMode ? '☀️' : '🌙'}
|
||||
@@ -287,7 +292,7 @@ export default function ColorsManagement() {
|
||||
placeholder="Pretraži boje..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 pl-10 pr-4 text-gray-700 dark:text-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">
|
||||
<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>
|
||||
|
||||
{/* Colors Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-white dark:bg-white/[0.04] rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.06]">
|
||||
<thead className="bg-gray-50 dark:bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
@@ -311,36 +316,36 @@ export default function ColorsManagement() {
|
||||
return filtered.length > 0 && filtered.every(c => selectedColors.has(c.id));
|
||||
})()}
|
||||
onChange={toggleSelectAll}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-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 className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Refil</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Cena Spulna</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Akcije</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Boja</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Naziv</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Hex kod</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Refil</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Cena Spulna</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-white/60 uppercase tracking-wider">Akcije</th>
|
||||
</tr>
|
||||
</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
|
||||
.filter(color =>
|
||||
color.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
color.hex.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map((color) => (
|
||||
<tr key={color.id} className="hover:bg-gray-50 dark:hover:bg-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">
|
||||
<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-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 className="px-6 py-4 whitespace-nowrap">
|
||||
<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 }}
|
||||
/>
|
||||
</td>
|
||||
@@ -384,7 +389,7 @@ export default function ColorsManagement() {
|
||||
{/* Edit Modal */}
|
||||
{editingColor && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-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">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Izmeni boju</h3>
|
||||
<ColorForm
|
||||
@@ -449,7 +454,7 @@ function ColorForm({
|
||||
};
|
||||
|
||||
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 && (
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
|
||||
{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">
|
||||
<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
|
||||
type="text"
|
||||
name="name"
|
||||
@@ -465,12 +470,12 @@ function ColorForm({
|
||||
onChange={handleChange}
|
||||
required
|
||||
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>
|
||||
<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">
|
||||
<input
|
||||
type="color"
|
||||
@@ -478,7 +483,7 @@ function ColorForm({
|
||||
value={isBambuLabColor && bambuHex ? bambuHex : formData.hex}
|
||||
onChange={handleChange}
|
||||
disabled={isBambuLabColor}
|
||||
className={`w-12 h-12 p-1 border-2 border-gray-300 dark:border-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 }}
|
||||
/>
|
||||
<input
|
||||
@@ -490,18 +495,18 @@ function ColorForm({
|
||||
readOnly={isBambuLabColor}
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
placeholder="#000000"
|
||||
className={`flex-1 px-3 py-2 border border-gray-300 dark:border-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>
|
||||
{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
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
type="number"
|
||||
name="cena_refill"
|
||||
@@ -511,12 +516,12 @@ function ColorForm({
|
||||
min="0"
|
||||
step="1"
|
||||
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>
|
||||
<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
|
||||
type="number"
|
||||
name="cena_spulna"
|
||||
@@ -526,7 +531,7 @@ function ColorForm({
|
||||
min="0"
|
||||
step="1"
|
||||
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>
|
||||
|
||||
@@ -534,7 +539,7 @@ function ColorForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-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
|
||||
</button>
|
||||
|
||||
13
app/upadaj/dashboard/[category]/layout.tsx
Normal file
13
app/upadaj/dashboard/[category]/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
577
app/upadaj/dashboard/[category]/page.tsx
Normal file
577
app/upadaj/dashboard/[category]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
460
app/upadaj/dashboard/analitika/page.tsx
Normal file
460
app/upadaj/dashboard/analitika/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
448
app/upadaj/dashboard/boje/page.tsx
Normal file
448
app/upadaj/dashboard/boje/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
990
app/upadaj/dashboard/filamenti/page.tsx
Normal file
990
app/upadaj/dashboard/filamenti/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/upadaj/dashboard/layout.tsx
Normal file
8
app/upadaj/dashboard/layout.tsx
Normal 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
360
app/upadaj/dashboard/prodaja/page.tsx
Normal file
360
app/upadaj/dashboard/prodaja/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal file
334
app/upadaj/dashboard/zahtevi/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,8 @@ export default function AdminLogin() {
|
||||
// Track successful login
|
||||
trackEvent('Admin', 'Login', 'Success');
|
||||
|
||||
// Redirect to admin dashboard
|
||||
router.push('/upadaj/dashboard');
|
||||
// Redirect to admin dashboard using window.location for static export
|
||||
window.location.href = '/upadaj/dashboard';
|
||||
} catch (err: any) {
|
||||
setError('Neispravno korisničko ime ili lozinka');
|
||||
console.error('Login error:', err);
|
||||
@@ -44,7 +44,7 @@ export default function AdminLogin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-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="flex flex-col items-center">
|
||||
<img
|
||||
@@ -52,20 +52,23 @@ export default function AdminLogin() {
|
||||
alt="Filamenteka"
|
||||
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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
|
||||
<div className="rounded-xl bg-red-500/10 border border-red-500/20 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Korisničko ime
|
||||
@@ -76,7 +79,7 @@ export default function AdminLogin() {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
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"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -92,7 +95,7 @@ export default function AdminLogin() {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
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"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -104,7 +107,8 @@ export default function AdminLogin() {
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function ColorRequestsAdmin() {
|
||||
const expiry = localStorage.getItem('tokenExpiry');
|
||||
|
||||
if (!token || !expiry || new Date().getTime() > parseInt(expiry)) {
|
||||
router.push('/upadaj');
|
||||
window.location.href = '/upadaj';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ColorRequestsAdmin() {
|
||||
rejected: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||
completed: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-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) => {
|
||||
@@ -112,20 +112,20 @@ export default function ColorRequestsAdmin() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">Učitavanje zahteva za boje...</div>
|
||||
<div 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-gray-900">
|
||||
<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="/upadaj/dashboard"
|
||||
href="/dashboard"
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Inventar
|
||||
@@ -145,42 +145,42 @@ export default function ColorRequestsAdmin() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<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-gray-700">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-gray-400 uppercase tracking-wider">
|
||||
<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-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]">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<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-gray-400 mt-1">{request.description}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-white/40 mt-1">{request.description}</div>
|
||||
)}
|
||||
{request.reference_url && (
|
||||
<a
|
||||
@@ -198,7 +198,7 @@ export default function ColorRequestsAdmin() {
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-900 dark:text-gray-100">{request.material_type}</div>
|
||||
{request.finish_type && (
|
||||
<div className="text-gray-500 dark:text-gray-400">{request.finish_type}</div>
|
||||
<div className="text-gray-500 dark:text-white/40">{request.finish_type}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -264,7 +264,7 @@ export default function ColorRequestsAdmin() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="text-sm text-gray-600 dark:text-white/40">
|
||||
{formatDate(request.created_at)}
|
||||
</div>
|
||||
{request.processed_at && (
|
||||
@@ -287,7 +287,7 @@ export default function ColorRequestsAdmin() {
|
||||
setEditingId(null);
|
||||
setEditForm({ status: '', admin_notes: '' });
|
||||
}}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
|
||||
className="text-gray-600 dark:text-white/40 hover:text-gray-800 dark:hover:text-gray-300 text-sm"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
@@ -322,33 +322,33 @@ export default function ColorRequestsAdmin() {
|
||||
</div>
|
||||
|
||||
{requests.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<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-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Ukupno Zahteva</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">Ukupno Zahteva</div>
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{requests.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Na Čekanju</div>
|
||||
<div className="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-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Odobreno</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-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Završeno</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>
|
||||
|
||||
208
app/uslovi-koriscenja/page.tsx
Normal file
208
app/uslovi-koriscenja/page.tsx
Normal 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 "takve kakve jesu". 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>
|
||||
);
|
||||
}
|
||||
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal file
77
database/migrations/019_add_new_bambu_colors_2025.sql
Normal 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
|
||||
);
|
||||
102
database/migrations/020_add_missing_colors_2025.sql
Normal file
102
database/migrations/020_add_missing_colors_2025.sql
Normal 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;
|
||||
32
database/migrations/021_create_products_table.sql
Normal file
32
database/migrations/021_create_products_table.sql
Normal 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);
|
||||
25
database/migrations/022_create_printer_compatibility.sql
Normal file
25
database/migrations/022_create_printer_compatibility.sql
Normal 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');
|
||||
12
database/migrations/023_create_product_images.sql
Normal file
12
database/migrations/023_create_product_images.sql
Normal 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
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
2491
package-lock.json
generated
2491
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -3,10 +3,10 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "./scripts/kill-dev.sh && next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint . --ext .ts,.tsx --ignore-path .gitignore",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"security:check": "node scripts/security/security-check.js",
|
||||
@@ -20,31 +20,31 @@
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cheerio": "^1.1.0",
|
||||
"next": "^15.3.4",
|
||||
"next": "^16.1.6",
|
||||
"pg": "^8.16.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
8
public/robots.txt
Normal file
8
public/robots.txt
Normal 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
48
public/sitemap.xml
Normal 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>
|
||||
92
scripts/add-missing-colors.js
Normal file
92
scripts/add-missing-colors.js
Normal 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();
|
||||
@@ -13,8 +13,8 @@ cd /home/ubuntu/filamenteka-api
|
||||
# Backup current server.js
|
||||
cp server.js server.js.backup
|
||||
|
||||
# Download the updated server.js from GitHub
|
||||
curl -o server.js https://raw.githubusercontent.com/daxdax89/Filamenteka/sale/api/server.js
|
||||
# Download the updated server.js from Gitea
|
||||
curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart node-api
|
||||
@@ -30,7 +30,7 @@ aws ssm send-command \
|
||||
--parameters "commands=[
|
||||
'cd /home/ubuntu/filamenteka-api',
|
||||
'cp server.js server.js.backup',
|
||||
'curl -o server.js https://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 status node-api'
|
||||
]" \
|
||||
|
||||
97
scripts/deploy-frontend.sh
Executable file
97
scripts/deploy-frontend.sh
Executable 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
26
scripts/kill-dev.sh
Executable 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!"
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
|
||||
echo "🚀 Updating API server with latest code..."
|
||||
|
||||
77
scripts/verify-color-consistency.js
Normal file
77
scripts/verify-color-consistency.js
Normal 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);
|
||||
}
|
||||
363
src/components/BulkFilamentPriceEditor.tsx
Normal file
363
src/components/BulkFilamentPriceEditor.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
288
src/components/BulkPriceEditor.tsx
Normal file
288
src/components/BulkPriceEditor.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
|
||||
finish_type: 'Basic',
|
||||
user_name: '',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
description: '',
|
||||
reference_url: ''
|
||||
});
|
||||
@@ -37,6 +38,7 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
|
||||
finish_type: 'Basic',
|
||||
user_name: '',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
description: '',
|
||||
reference_url: ''
|
||||
});
|
||||
@@ -136,12 +138,13 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
|
||||
|
||||
<div>
|
||||
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email (opciono)
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user_email"
|
||||
name="user_email"
|
||||
required
|
||||
value={formData.user_email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||
@@ -150,6 +153,23 @@ export default function ColorRequestForm({ onSuccess }: ColorRequestFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="user_phone"
|
||||
name="user_phone"
|
||||
required
|
||||
value={formData.user_phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||
placeholder="063 123 4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import ColorRequestModal from './ColorRequestModal';
|
||||
import { trackEvent } from './MatomoAnalytics';
|
||||
|
||||
export default function ColorRequestSection() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||
<svg className="w-16 h-16 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Zatražite Novu Boju
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
Ne možete pronaći boju koju tražite u našoj ponudi?
|
||||
Javite nam koju boju želite i mi ćemo je nabaviti za vas!
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
trackEvent('Navigation', 'Request Color', 'Color Request Tab');
|
||||
}}
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Pošaljite Zahtev za Boju
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">1-3</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za odgovor</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100+</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dostupnih boja</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">7-14</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">Dana za nabavku</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorRequestModal isOpen={showModal} onClose={() => setShowModal(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,16 +157,16 @@ const FilamentTableV2: React.FC<FilamentTableV2Props> = ({ filaments }) => {
|
||||
Tip {sortField === 'tip' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('finish')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Finish {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
Finiš {sortField === 'finish' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('boja')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Boja {sortField === 'boja' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('refill')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Refill {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
Refil {sortField === 'refill' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('spulna')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Spulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
Špulna {sortField === 'spulna' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th onClick={() => handleSort('kolicina')} className="px-2 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
Količina {sortField === 'kolicina' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { gearRequestService } from '@/src/services/api';
|
||||
|
||||
interface GearRequestModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function GearRequestModal({ isOpen, onClose }: GearRequestModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
item_name: '',
|
||||
category: '',
|
||||
printer_model: '',
|
||||
quantity: '1',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
message: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFormData({
|
||||
item_name: '',
|
||||
category: '',
|
||||
printer_model: '',
|
||||
quantity: '1',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
message: ''
|
||||
});
|
||||
setMessage(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await gearRequestService.submit(formData);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: 'Vaš zahtev za opremu je uspešno poslat!'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||
Zatraži Opremu ili Deo
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Recite nam koji deo ili opremu tražite!
|
||||
</p>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-3 rounded text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="item_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Naziv Proizvoda *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="item_name"
|
||||
name="item_name"
|
||||
required
|
||||
value={formData.item_name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="npr. Hardened Steel Nozzle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Kategorija
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Izaberite</option>
|
||||
<option value="nozzle">Dizna</option>
|
||||
<option value="hotend">Hotend</option>
|
||||
<option value="extruder">Ekstruder</option>
|
||||
<option value="bed">Podloga</option>
|
||||
<option value="tool">Alat</option>
|
||||
<option value="spare_part">Rezervni Deo</option>
|
||||
<option value="accessory">Dodatak</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Količina
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
min="1"
|
||||
value={formData.quantity}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="printer_model" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Model Štampača
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="printer_model"
|
||||
name="printer_model"
|
||||
value={formData.printer_model}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="npr. Bambu Lab X1C"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Dodatne Napomene
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={3}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Opišite šta vam je potrebno..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user_email"
|
||||
name="user_email"
|
||||
required
|
||||
value={formData.user_email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="vas@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="user_phone"
|
||||
name="user_phone"
|
||||
required
|
||||
value={formData.user_phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="06x xxx xxxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-6 py-2 rounded-md text-white font-medium ${
|
||||
isSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 hover:bg-purple-700'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isSubmitting ? 'Slanje...' : 'Pošalji'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { gearService } from '@/src/services/api';
|
||||
import GearRequestModal from './GearRequestModal';
|
||||
|
||||
interface GearItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'nozzle' | 'hotend' | 'extruder' | 'bed' | 'tool' | 'spare_part' | 'accessory';
|
||||
price: string;
|
||||
availability: 'available' | 'out_of_stock' | 'pre_order';
|
||||
description: string;
|
||||
compatible_with?: string[];
|
||||
image_url?: string;
|
||||
}
|
||||
|
||||
export default function GearTable() {
|
||||
const [gear, setGear] = useState<GearItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetchGear();
|
||||
}, []);
|
||||
|
||||
const fetchGear = async () => {
|
||||
try {
|
||||
const data = await gearService.getAll();
|
||||
setGear(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching gear:', error);
|
||||
// Use mock data for now until API is ready
|
||||
setGear([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Hardened Steel Nozzle 0.4mm',
|
||||
category: 'nozzle',
|
||||
price: '29€',
|
||||
availability: 'available',
|
||||
description: 'Otporna na habanje, idealna za abrazivne materijale',
|
||||
compatible_with: ['X1C', 'P1S', 'A1']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'PEI Textured Plate',
|
||||
category: 'bed',
|
||||
price: '39€',
|
||||
availability: 'available',
|
||||
description: 'Teksturisana PEI ploča za bolju adheziju',
|
||||
compatible_with: ['X1C', 'P1S']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'AMS Hub',
|
||||
category: 'accessory',
|
||||
price: '149€',
|
||||
availability: 'available',
|
||||
description: 'Hub za povezivanje do 4 AMS jedinice',
|
||||
compatible_with: ['X1C', 'P1S']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Bambu Lab Tool Kit',
|
||||
category: 'tool',
|
||||
price: '49€',
|
||||
availability: 'available',
|
||||
description: 'Kompletan set alata za održavanje štampača'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Hotend Assembly',
|
||||
category: 'hotend',
|
||||
price: '89€',
|
||||
availability: 'pre_order',
|
||||
description: 'Kompletna hotend jedinica',
|
||||
compatible_with: ['X1C', 'P1S']
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Carbon Filter',
|
||||
category: 'spare_part',
|
||||
price: '19€',
|
||||
availability: 'available',
|
||||
description: 'Aktivni ugljeni filter za X1C',
|
||||
compatible_with: ['X1C']
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Extruder Gear Set',
|
||||
category: 'extruder',
|
||||
price: '25€',
|
||||
availability: 'available',
|
||||
description: 'Set zupčanika za ekstruder',
|
||||
compatible_with: ['X1C', 'P1S', 'A1']
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'LED Light Bar',
|
||||
category: 'accessory',
|
||||
price: '35€',
|
||||
availability: 'available',
|
||||
description: 'LED osvetljenje za komoru štampača',
|
||||
compatible_with: ['X1C', 'P1S']
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Sve' },
|
||||
{ value: 'nozzle', label: 'Dizne' },
|
||||
{ value: 'hotend', label: 'Hotend' },
|
||||
{ value: 'extruder', label: 'Ekstruder' },
|
||||
{ value: 'bed', label: 'Podloge' },
|
||||
{ value: 'tool', label: 'Alati' },
|
||||
{ value: 'spare_part', label: 'Rezervni Delovi' },
|
||||
{ value: 'accessory', label: 'Dodaci' }
|
||||
];
|
||||
|
||||
const filteredGear = selectedCategory === 'all'
|
||||
? gear
|
||||
: gear.filter(item => item.category === selectedCategory);
|
||||
|
||||
const getAvailabilityBadge = (availability: string) => {
|
||||
switch (availability) {
|
||||
case 'available':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Dostupno
|
||||
</span>
|
||||
);
|
||||
case 'out_of_stock':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
Nema na stanju
|
||||
</span>
|
||||
);
|
||||
case 'pre_order':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
Prednarudžba
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const categoryLabel = categories.find(c => c.value === category)?.label || category;
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
{categoryLabel}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Oprema i Delovi</h2>
|
||||
<button
|
||||
onClick={() => setIsRequestModalOpen(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Zatraži Opremu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Proizvod
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Kategorija
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Kompatibilnost
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cena
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredGear.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getCategoryBadge(item.category)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{item.compatible_with ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.compatible_with.map((model) => (
|
||||
<span key={model} className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400 px-2 py-1 rounded">
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Univerzalno</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-lg font-semibold text-purple-600 dark:text-purple-400">
|
||||
{item.price}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getAvailabilityBadge(item.availability)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<GearRequestModal isOpen={isRequestModalOpen} onClose={() => setIsRequestModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { printerRequestService } from '@/src/services/api';
|
||||
|
||||
interface PrinterRequestModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PrinterRequestModal({ isOpen, onClose }: PrinterRequestModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
printer_model: '',
|
||||
budget_range: '',
|
||||
intended_use: '',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
message: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFormData({
|
||||
printer_model: '',
|
||||
budget_range: '',
|
||||
intended_use: '',
|
||||
user_email: '',
|
||||
user_phone: '',
|
||||
message: ''
|
||||
});
|
||||
setMessage(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await printerRequestService.submit(formData);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: 'Vaš zahtev za štampač je uspešno poslat!'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Greška pri slanju zahteva. Pokušajte ponovo.'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||
Zatraži 3D Štampač
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Recite nam koji štampač vas zanima ili koji vam odgovara!
|
||||
</p>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-3 rounded text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="printer_model" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Model Štampača
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="printer_model"
|
||||
name="printer_model"
|
||||
value={formData.printer_model}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="npr. Bambu Lab X1 Carbon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="budget_range" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Budžet
|
||||
</label>
|
||||
<select
|
||||
id="budget_range"
|
||||
name="budget_range"
|
||||
value={formData.budget_range}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Izaberite budžet</option>
|
||||
<option value="0-500">0 - 500€</option>
|
||||
<option value="500-1000">500 - 1000€</option>
|
||||
<option value="1000-2000">1000 - 2000€</option>
|
||||
<option value="2000+">2000€+</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="intended_use" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Namena Korišćenja
|
||||
</label>
|
||||
<select
|
||||
id="intended_use"
|
||||
name="intended_use"
|
||||
value={formData.intended_use}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Izaberite namenu</option>
|
||||
<option value="hobby">Hobi</option>
|
||||
<option value="professional">Profesionalno</option>
|
||||
<option value="education">Edukacija</option>
|
||||
<option value="prototyping">Prototipovi</option>
|
||||
<option value="production">Proizvodnja</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Dodatne Napomene
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={3}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Opišite vaše potrebe..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="user_email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user_email"
|
||||
name="user_email"
|
||||
required
|
||||
value={formData.user_email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="vas@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="user_phone" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="user_phone"
|
||||
name="user_phone"
|
||||
required
|
||||
value={formData.user_phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="06x xxx xxxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Otkaži
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-6 py-2 rounded-md text-white font-medium ${
|
||||
isSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 hover:bg-purple-700'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isSubmitting ? 'Slanje...' : 'Pošalji'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { printersService } from '@/src/services/api';
|
||||
import PrinterRequestModal from './PrinterRequestModal';
|
||||
|
||||
interface Printer {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
price: string;
|
||||
availability: 'available' | 'out_of_stock' | 'pre_order';
|
||||
description: string;
|
||||
image_url?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export default function PrintersTable() {
|
||||
const [printers, setPrinters] = useState<Printer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrinters();
|
||||
}, []);
|
||||
|
||||
const fetchPrinters = async () => {
|
||||
try {
|
||||
const data = await printersService.getAll();
|
||||
setPrinters(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching printers:', error);
|
||||
// Use mock data for now until API is ready
|
||||
setPrinters([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Bambu Lab X1 Carbon',
|
||||
model: 'X1C',
|
||||
price: '1449€',
|
||||
availability: 'available',
|
||||
description: 'Professional 3D printer with automatic calibration',
|
||||
features: ['AMS compatible', 'LiDAR scanning', 'AI error detection']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Bambu Lab P1S',
|
||||
model: 'P1S',
|
||||
price: '849€',
|
||||
availability: 'available',
|
||||
description: 'Enclosed 3D printer for advanced materials',
|
||||
features: ['Enclosed chamber', 'Camera monitoring', 'Silent operation']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Bambu Lab A1 mini',
|
||||
model: 'A1 mini',
|
||||
price: '299€',
|
||||
availability: 'available',
|
||||
description: 'Compact desktop 3D printer',
|
||||
features: ['Auto-leveling', 'Compact size', 'Beginner friendly']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Bambu Lab A1',
|
||||
model: 'A1',
|
||||
price: '459€',
|
||||
availability: 'pre_order',
|
||||
description: 'Mid-size desktop 3D printer with AMS lite',
|
||||
features: ['AMS lite compatible', 'Auto-calibration', 'Quick-swap nozzle']
|
||||
}
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailabilityBadge = (availability: string) => {
|
||||
switch (availability) {
|
||||
case 'available':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Dostupno
|
||||
</span>
|
||||
);
|
||||
case 'out_of_stock':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
Nema na stanju
|
||||
</span>
|
||||
);
|
||||
case 'pre_order':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
Prednarudžba
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">3D Štampači</h2>
|
||||
<button
|
||||
onClick={() => setIsRequestModalOpen(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Zatraži Štampač</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{printers.map((printer) => (
|
||||
<div key={printer.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
{printer.image_url && (
|
||||
<div className="h-48 bg-gray-200 dark:bg-gray-700">
|
||||
<img src={printer.image_url} alt={printer.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{printer.name}</h3>
|
||||
{getAvailabilityBadge(printer.availability)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">Model: {printer.model}</p>
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mb-3">{printer.price}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">{printer.description}</p>
|
||||
{printer.features && printer.features.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{printer.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg className="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PrinterRequestModal isOpen={isRequestModalOpen} onClose={() => setIsRequestModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabbedNavigationProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export default function TabbedNavigation({ tabs, activeTab, onTabChange }: TabbedNavigationProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex justify-center space-x-4 sm:space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-500 text-purple-600 dark:text-purple-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/catalog/CatalogFilters.tsx
Normal file
165
src/components/catalog/CatalogFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
676
src/components/catalog/CatalogPage.tsx
Normal file
676
src/components/catalog/CatalogPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/components/catalog/FilamentCard.tsx
Normal file
188
src/components/catalog/FilamentCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/components/catalog/FilamentRow.tsx
Normal file
125
src/components/catalog/FilamentRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/catalog/ProductCard.tsx
Normal file
118
src/components/catalog/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/catalog/ProductGrid.tsx
Normal file
34
src/components/catalog/ProductGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/catalog/ProductTable.tsx
Normal file
94
src/components/catalog/ProductTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/layout/AdminLayout.tsx
Normal file
51
src/components/layout/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
src/components/layout/AdminSidebar.tsx
Normal file
286
src/components/layout/AdminSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/layout/Breadcrumb.tsx
Normal file
84
src/components/layout/Breadcrumb.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/components/layout/CategoryNav.tsx
Normal file
85
src/components/layout/CategoryNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/layout/SiteFooter.tsx
Normal file
154
src/components/layout/SiteFooter.tsx
Normal 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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
238
src/components/layout/SiteHeader.tsx
Normal file
238
src/components/layout/SiteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/ui/Badge.tsx
Normal file
62
src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/Button.tsx
Normal file
76
src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/ui/Card.tsx
Normal file
44
src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/CategoryIcon.tsx
Normal file
76
src/components/ui/CategoryIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/components/ui/EmptyState.tsx
Normal file
50
src/components/ui/EmptyState.tsx
Normal 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
104
src/components/ui/Modal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/components/ui/PriceDisplay.tsx
Normal file
52
src/components/ui/PriceDisplay.tsx
Normal 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
148
src/config/categories.ts
Normal 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',
|
||||
};
|
||||
59
src/contexts/DarkModeContext.tsx
Normal file
59
src/contexts/DarkModeContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Charcoal': { hex: '#000000' },
|
||||
'Cherry Pink': { hex: '#E9B6CC' },
|
||||
'Chocolate': { hex: '#4A3729' },
|
||||
'Classic Birch': { hex: '#E8D5B7' },
|
||||
'Classic Gold Sparkle': { hex: '#E4BD68' },
|
||||
'Clay Brown': { hex: '#8E621A' },
|
||||
'Clear': { hex: '#FAFAFA' },
|
||||
'Clear Black': { hex: '#5A5161' },
|
||||
@@ -70,6 +72,8 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Indigo Purple': { hex: '#482A60' },
|
||||
'Iridium Gold Metallic': { hex: '#B39B84' },
|
||||
'Iris Purple': { hex: '#69398E' },
|
||||
'Iron Gray Metallic': { hex: '#6B6C6F' },
|
||||
'IronGray Metallic': { hex: '#6B6C6F' },
|
||||
'Ivory White': { hex: '#FFFFFF' },
|
||||
'Jade White': { hex: '#FFFFFF' },
|
||||
'Jeans Blue': { hex: '#6E88BC' },
|
||||
@@ -99,6 +103,7 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'Nardo Gray': { hex: '#747474' },
|
||||
'Navy Blue': { hex: '#0C2340' },
|
||||
'Nebulae': { hex: '#424379' },
|
||||
'Nebulane': { hex: '#424379' },
|
||||
'Neon City': { hex: '#0047BB' },
|
||||
'Neon Green': { hex: '#ABFF1E' },
|
||||
'Neon Orange': { hex: '#F68A1B' },
|
||||
@@ -141,6 +146,62 @@ export const bambuLabColors: Record<string, ColorMapping> = {
|
||||
'White Oak': { hex: '#D2CCA2' },
|
||||
'Yellow': { hex: '#F4EE2A' },
|
||||
|
||||
// ABS Colors
|
||||
// ABS GF Colors
|
||||
'ABS GF Yellow': { hex: '#FDD835' },
|
||||
'ABS GF Orange': { hex: '#F48438' },
|
||||
|
||||
// ABS Colors
|
||||
'ABS Azure': { hex: '#489FDF' },
|
||||
'ABS Olive': { hex: '#748C45' },
|
||||
'ABS Blue': { hex: '#0A2989' },
|
||||
'ABS Tangerine Yellow': { hex: '#FFC72C' },
|
||||
'ABS Navy Blue': { hex: '#0C2340' },
|
||||
'ABS Orange': { hex: '#FF6A13' },
|
||||
'ABS Bambu Green': { hex: '#00AE42' },
|
||||
'ABS Red': { hex: '#C12E1F' },
|
||||
'ABS White': { hex: '#FFFFFF' },
|
||||
'ABS Black': { hex: '#000000' },
|
||||
'ABS Silver': { hex: '#A6A9AA' },
|
||||
|
||||
// Translucent Colors
|
||||
'Translucent Gray': { hex: '#B8B8B8' },
|
||||
'Translucent Brown': { hex: '#C89A74' },
|
||||
'Translucent Purple': { hex: '#C5A8D8' },
|
||||
'Translucent Orange': { hex: '#FFB380' },
|
||||
'Translucent Olive': { hex: '#A4B885' },
|
||||
'Translucent Pink': { hex: '#F9B8D0' },
|
||||
'Translucent Light Blue': { hex: '#A8D8F0' },
|
||||
'Translucent Tea': { hex: '#D9C7A8' },
|
||||
|
||||
// PLA Matte - New Colors (2025)
|
||||
'Matte Apple Green': { hex: '#C6E188' },
|
||||
'Matte Bone White': { hex: '#C8C5B6' },
|
||||
'Matte Caramel': { hex: '#A4845C' },
|
||||
'Matte Dark Blue': { hex: '#042F56' },
|
||||
'Matte Dark Brown': { hex: '#7D6556' },
|
||||
'Matte Dark Chocolate': { hex: '#4A3729' },
|
||||
'Matte Dark Green': { hex: '#68724D' },
|
||||
'Matte Dark Red': { hex: '#BB3D43' },
|
||||
'Matte Grass Green': { hex: '#7CB342' },
|
||||
'Matte Ice Blue': { hex: '#A3D8E1' },
|
||||
'Matte Ivory': { hex: '#FFFFF0' },
|
||||
'Matte Lemon Yellow': { hex: '#F7D959' },
|
||||
'Matte Lilac Purple': { hex: '#AE96D4' },
|
||||
'Matte Plum': { hex: '#851A52' },
|
||||
'Matte Sakura Pink': { hex: '#E8AFCF' },
|
||||
'Matte Sky Blue': { hex: '#73B2E5' },
|
||||
'Matte Latte Brown': { hex: '#D3B7A7' },
|
||||
'Matte Terracotta': { hex: '#A25A37' },
|
||||
|
||||
// PLA Silk Multi-Color
|
||||
'Silk Aurora Purple': { hex: ['#7F3696', '#006EC9'], isGradient: true },
|
||||
'Silk Phantom Blue': { hex: ['#00629B', '#000000'], isGradient: true },
|
||||
'Silk Mystic Magenta': { hex: ['#720062', '#3A913F'], isGradient: true },
|
||||
|
||||
// TPU 95A HF Colors
|
||||
'TPU 95A HF Yellow': { hex: '#F3E600' },
|
||||
|
||||
// Default fallback
|
||||
'Unknown': { hex: '#CCCCCC' }
|
||||
};
|
||||
|
||||
@@ -40,6 +40,25 @@ export const bambuLabColors = {
|
||||
"Matte Navy": "#1A237E",
|
||||
"Matte Coral": "#FF5252",
|
||||
|
||||
// Matte Colors - New 2025
|
||||
"Matte Apple Green": "#C6E188",
|
||||
"Matte Bone White": "#C8C5B6",
|
||||
"Matte Caramel": "#A4845C",
|
||||
"Matte Dark Blue": "#042F56",
|
||||
"Matte Dark Brown": "#7D6556",
|
||||
"Matte Dark Chocolate": "#4A3729",
|
||||
"Matte Dark Green": "#68724D",
|
||||
"Matte Dark Red": "#BB3D43",
|
||||
"Matte Grass Green": "#61C680",
|
||||
"Matte Ice Blue": "#A3D8E1",
|
||||
"Matte Lemon Yellow": "#F7D959",
|
||||
"Matte Lilac Purple": "#AE96D4",
|
||||
"Matte Plum": "#851A52",
|
||||
"Matte Sakura Pink": "#E8AFCF",
|
||||
"Matte Sky Blue": "#73B2E5",
|
||||
"Matte Latte Brown": "#D3B7A7",
|
||||
"Matte Terracotta": "#A25A37",
|
||||
|
||||
// Silk Colors
|
||||
"Silk White": "#FEFEFE",
|
||||
"Silk Black": "#0A0A0A",
|
||||
@@ -60,6 +79,11 @@ export const bambuLabColors = {
|
||||
"Silk Sapphire": "#1976D2",
|
||||
"Silk Emerald": "#00695C",
|
||||
|
||||
// Silk Multi-Color
|
||||
"Silk Aurora Purple": "#7F3696",
|
||||
"Silk Phantom Blue": "#00629B",
|
||||
"Silk Mystic Magenta": "#720062",
|
||||
|
||||
// Metal Colors
|
||||
"Metal Grey": "#9E9E9E",
|
||||
"Metal Silver": "#B0BEC5",
|
||||
@@ -91,7 +115,22 @@ export const bambuLabColors = {
|
||||
// Support Materials
|
||||
"Natural": "#F5F5DC",
|
||||
"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
|
||||
@@ -105,7 +144,11 @@ export const colorsByFinish = {
|
||||
"Matte": [
|
||||
"Matte Black", "Matte White", "Matte Red", "Matte Blue", "Matte Green",
|
||||
"Matte Yellow", "Matte Orange", "Matte Purple", "Matte Pink", "Matte Grey",
|
||||
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral"
|
||||
"Matte Brown", "Matte Mint", "Matte Lime", "Matte Navy", "Matte Coral",
|
||||
"Matte Apple Green", "Matte Bone White", "Matte Caramel", "Matte Dark Blue",
|
||||
"Matte Dark Brown", "Matte Dark Chocolate", "Matte Dark Green", "Matte Dark Red",
|
||||
"Matte Grass Green", "Matte Ice Blue", "Matte Lemon Yellow", "Matte Lilac Purple",
|
||||
"Matte Latte Brown", "Matte Plum", "Matte Sakura Pink", "Matte Sky Blue", "Matte Terracotta"
|
||||
],
|
||||
"Silk": [
|
||||
"Silk White", "Silk Black", "Silk Red", "Silk Blue", "Silk Green",
|
||||
@@ -129,6 +172,9 @@ export const colorsByFinish = {
|
||||
],
|
||||
"Support": [
|
||||
"Natural", "Support White", "Support G"
|
||||
],
|
||||
"Wood": [
|
||||
"Ochre Yellow", "White Oak", "Clay Brown"
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
36
src/hooks/useAuth.ts
Normal file
36
src/hooks/useAuth.ts
Normal 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
47
src/hooks/useProducts.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { Filament } from '@/src/types/filament';
|
||||
import { Product, ProductCategory, InventoryStats, SalesStats } from '@/src/types/product';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
@@ -111,7 +112,8 @@ export const colorRequestService = {
|
||||
color_name: string;
|
||||
material_type: string;
|
||||
finish_type?: string;
|
||||
user_email?: string;
|
||||
user_email: string;
|
||||
user_phone: string;
|
||||
user_name?: string;
|
||||
description?: string;
|
||||
reference_url?: string;
|
||||
@@ -131,47 +133,73 @@ export const colorRequestService = {
|
||||
},
|
||||
};
|
||||
|
||||
export const printersService = {
|
||||
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 () => {
|
||||
try {
|
||||
const response = await api.get('/printers');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
const response = await api.get('/printer-models');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const printerRequestService = {
|
||||
submit: async (request: any) => {
|
||||
try {
|
||||
const response = await api.post('/printer-requests', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { success: true };
|
||||
}
|
||||
export const analyticsService = {
|
||||
getInventory: async (): Promise<InventoryStats> => {
|
||||
const response = await api.get('/analytics/inventory');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const gearService = {
|
||||
getAll: async () => {
|
||||
try {
|
||||
const response = await api.get('/gear');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const gearRequestService = {
|
||||
submit: async (request: any) => {
|
||||
try {
|
||||
const response = await api.post('/gear-requests', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { success: true };
|
||||
}
|
||||
getSales: async (): Promise<SalesStats> => {
|
||||
const response = await api.get('/analytics/sales');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 components;
|
||||
@tailwind utilities;
|
||||
@@ -10,43 +12,244 @@
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Prevent white flash on admin pages */
|
||||
@layer base {
|
||||
html {
|
||||
background-color: rgb(249 250 251);
|
||||
:root {
|
||||
--font-display: 'Sora', system-ui, sans-serif;
|
||||
--font-body: 'Nunito Sans', system-ui, sans-serif;
|
||||
|
||||
/* Category palette */
|
||||
--color-filamenti: #3b82f6;
|
||||
--color-stampaci: #ef4444;
|
||||
--color-ploce: #22c55e;
|
||||
--color-mlaznice: #f59e0b;
|
||||
--color-delovi: #a855f7;
|
||||
--color-oprema: #06b6d4;
|
||||
|
||||
/* Surface tokens — light (vibrant, clean) */
|
||||
--surface-primary: #f8fafc;
|
||||
--surface-secondary: #f1f5f9;
|
||||
--surface-elevated: #ffffff;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #e2e8f0;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
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 {
|
||||
@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 * {
|
||||
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 {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(200%); }
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Safari form styling fixes */
|
||||
/* ─── 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 {
|
||||
/* Remove Safari's default styling for form inputs */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="url"],
|
||||
@@ -61,7 +264,6 @@
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Fix Safari select arrow */
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
@@ -70,19 +272,14 @@
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode select arrow */
|
||||
.dark select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23d1d5db' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
/* Ensure consistent border radius on iOS */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: 0.375rem;
|
||||
input, select, textarea {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Remove iOS zoom on focus */
|
||||
@media screen and (max-width: 768px) {
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
|
||||
73
src/types/product.ts
Normal file
73
src/types/product.ts
Normal 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;
|
||||
}
|
||||
198
terraform/cloudfront-frontend.tf
Normal file
198
terraform/cloudfront-frontend.tf
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
terraform/cloudfront-function.js
Normal file
18
terraform/cloudfront-function.js
Normal 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;
|
||||
}
|
||||
@@ -20,117 +20,5 @@ provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token != "" ? var.cloudflare_api_token : "dummy" # Dummy token if not provided
|
||||
}
|
||||
|
||||
resource "aws_amplify_app" "filamenteka" {
|
||||
name = "filamenteka"
|
||||
repository = var.github_repository
|
||||
platform = "WEB"
|
||||
|
||||
# GitHub access token for private repos (optional for public repos)
|
||||
# access_token = var.github_token
|
||||
|
||||
# Build settings for Next.js
|
||||
build_spec = <<-EOT
|
||||
version: 1
|
||||
frontend:
|
||||
phases:
|
||||
preBuild:
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run security:check
|
||||
build:
|
||||
commands:
|
||||
- npm run build
|
||||
- npm run test
|
||||
artifacts:
|
||||
baseDirectory: .next
|
||||
files:
|
||||
- '**/*'
|
||||
cache:
|
||||
paths:
|
||||
- node_modules/**/*
|
||||
- .next/cache/**/*
|
||||
EOT
|
||||
|
||||
# Environment variables
|
||||
environment_variables = {
|
||||
NEXT_PUBLIC_API_URL = "https://api.filamenteka.rs/api" # Using Cloudflare proxied subdomain
|
||||
}
|
||||
|
||||
# Custom rules for single-page app
|
||||
custom_rule {
|
||||
source = "/<*>"
|
||||
status = "404"
|
||||
target = "/index.html"
|
||||
}
|
||||
|
||||
# Enable branch auto build
|
||||
enable_branch_auto_build = true
|
||||
|
||||
tags = {
|
||||
Name = "Filamenteka"
|
||||
Environment = var.environment
|
||||
# Disable GitHub App migration prompt
|
||||
"amplify:github_app_migration" = "opted_out"
|
||||
}
|
||||
}
|
||||
|
||||
# Main branch
|
||||
resource "aws_amplify_branch" "main" {
|
||||
app_id = aws_amplify_app.filamenteka.id
|
||||
branch_name = "main"
|
||||
|
||||
# Enable auto build
|
||||
enable_auto_build = true
|
||||
|
||||
# Environment variables specific to this branch (optional)
|
||||
environment_variables = {}
|
||||
|
||||
stage = "PRODUCTION"
|
||||
|
||||
tags = {
|
||||
Name = "Filamenteka-main"
|
||||
Environment = var.environment
|
||||
}
|
||||
}
|
||||
|
||||
# Development branch (optional)
|
||||
resource "aws_amplify_branch" "dev" {
|
||||
app_id = aws_amplify_app.filamenteka.id
|
||||
branch_name = "dev"
|
||||
|
||||
enable_auto_build = true
|
||||
|
||||
stage = "DEVELOPMENT"
|
||||
|
||||
tags = {
|
||||
Name = "Filamenteka-dev"
|
||||
Environment = "development"
|
||||
}
|
||||
}
|
||||
|
||||
# Custom domain (optional)
|
||||
resource "aws_amplify_domain_association" "filamenteka" {
|
||||
count = var.domain_name != "" ? 1 : 0
|
||||
|
||||
app_id = aws_amplify_app.filamenteka.id
|
||||
domain_name = var.domain_name
|
||||
wait_for_verification = false
|
||||
|
||||
# Map main branch to root domain
|
||||
sub_domain {
|
||||
branch_name = aws_amplify_branch.main.branch_name
|
||||
prefix = ""
|
||||
}
|
||||
|
||||
# Map main branch to www subdomain
|
||||
sub_domain {
|
||||
branch_name = aws_amplify_branch.main.branch_name
|
||||
prefix = "www"
|
||||
}
|
||||
|
||||
# Map dev branch to dev subdomain
|
||||
sub_domain {
|
||||
branch_name = aws_amplify_branch.dev.branch_name
|
||||
prefix = "dev"
|
||||
}
|
||||
}
|
||||
# Frontend hosted on CloudFront + S3 (see cloudfront-frontend.tf)
|
||||
# CI/CD handled by Gitea Actions (see .gitea/workflows/deploy.yml)
|
||||
@@ -1,22 +1,23 @@
|
||||
output "app_id" {
|
||||
description = "The ID of the Amplify app"
|
||||
value = aws_amplify_app.filamenteka.id
|
||||
}
|
||||
|
||||
output "app_url" {
|
||||
description = "The default URL of the Amplify app"
|
||||
value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
|
||||
}
|
||||
|
||||
output "dev_url" {
|
||||
description = "The development branch URL"
|
||||
value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
|
||||
}
|
||||
|
||||
output "custom_domain_url" {
|
||||
description = "The custom domain URL (if configured)"
|
||||
value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
||||
}
|
||||
# ===== DEPRECATED: Amplify Outputs (removed after migration to CloudFront) =====
|
||||
# output "app_id" {
|
||||
# description = "The ID of the Amplify app"
|
||||
# value = aws_amplify_app.filamenteka.id
|
||||
# }
|
||||
#
|
||||
# output "app_url" {
|
||||
# description = "The default URL of the Amplify app"
|
||||
# value = "https://main.${aws_amplify_app.filamenteka.default_domain}"
|
||||
# }
|
||||
#
|
||||
# output "dev_url" {
|
||||
# description = "The development branch URL"
|
||||
# value = "https://dev.${aws_amplify_app.filamenteka.default_domain}"
|
||||
# }
|
||||
#
|
||||
# output "custom_domain_url" {
|
||||
# description = "The custom domain URL (if configured)"
|
||||
# value = var.domain_name != "" ? "https://${var.domain_name}" : "Not configured"
|
||||
# }
|
||||
|
||||
output "rds_endpoint" {
|
||||
value = aws_db_instance.filamenteka.endpoint
|
||||
@@ -59,3 +60,24 @@ output "aws_region" {
|
||||
description = "AWS Region"
|
||||
}
|
||||
|
||||
# CloudFront + S3 Frontend Outputs
|
||||
output "s3_bucket_name" {
|
||||
value = aws_s3_bucket.frontend.id
|
||||
description = "S3 bucket name for frontend"
|
||||
}
|
||||
|
||||
output "cloudfront_distribution_id" {
|
||||
value = aws_cloudfront_distribution.frontend.id
|
||||
description = "CloudFront distribution ID"
|
||||
}
|
||||
|
||||
output "cloudfront_domain_name" {
|
||||
value = aws_cloudfront_distribution.frontend.domain_name
|
||||
description = "CloudFront distribution domain name"
|
||||
}
|
||||
|
||||
output "frontend_url" {
|
||||
value = var.domain_name != "" ? "https://${var.domain_name}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
|
||||
description = "Frontend URL"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Copy this file to terraform.tfvars and fill in your values
|
||||
|
||||
# GitHub repository for Amplify
|
||||
github_repository = "https://github.com/yourusername/filamenteka"
|
||||
github_token = "ghp_your_github_token_here"
|
||||
|
||||
# Domain configuration
|
||||
domain_name = "filamenteka.yourdomain.com"
|
||||
|
||||
|
||||
@@ -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" {
|
||||
description = "Custom domain name (optional)"
|
||||
type = string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user